mcp-server-dexcom-health 0.1.0__py3-none-any.whl
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.
- mcp_server_dexcom/__init__.py +0 -0
- mcp_server_dexcom/server.py +891 -0
- mcp_server_dexcom_health-0.1.0.dist-info/METADATA +183 -0
- mcp_server_dexcom_health-0.1.0.dist-info/RECORD +6 -0
- mcp_server_dexcom_health-0.1.0.dist-info/WHEEL +4 -0
- mcp_server_dexcom_health-0.1.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pydexcom import Dexcom
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from statistics import mean, stdev
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP(
|
|
7
|
+
"Dexcom Glucose",
|
|
8
|
+
instructions="Access and analyze Dexcom CGM glucose data.",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
def get_dexcom_client() -> Dexcom:
|
|
12
|
+
"""Create Dexcom client from environment variables."""
|
|
13
|
+
username = os.getenv("DEXCOM_USERNAME")
|
|
14
|
+
password = os.getenv("DEXCOM_PASSWORD")
|
|
15
|
+
region = os.getenv("DEXCOM_REGION", "us")
|
|
16
|
+
|
|
17
|
+
if not username or not password:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
"DEXCOM_USERNAME and DEXCOM_PASSWORD environment variables required"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if region == "ous":
|
|
23
|
+
return Dexcom(username=username, password=password, region="ous")
|
|
24
|
+
elif region == "jp":
|
|
25
|
+
return Dexcom(username=username, password=password, region="jp")
|
|
26
|
+
else:
|
|
27
|
+
return Dexcom(username=username, password=password)
|
|
28
|
+
|
|
29
|
+
def parse_external_data(data: list[dict]) -> list:
|
|
30
|
+
"""Convert external data format to internal reading objects."""
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
|
|
33
|
+
class ExternalReading:
|
|
34
|
+
def __init__(self, glucose: int, timestamp: str):
|
|
35
|
+
self.value = glucose
|
|
36
|
+
self.datetime = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
|
37
|
+
|
|
38
|
+
return [ExternalReading(d["glucose_mg_dl"], d["timestamp"]) for d in data]
|
|
39
|
+
|
|
40
|
+
@mcp.tool()
|
|
41
|
+
def get_current_glucose() -> dict:
|
|
42
|
+
"""
|
|
43
|
+
Get the current glucose reading.
|
|
44
|
+
|
|
45
|
+
Returns the most recent glucose value, trend direction,
|
|
46
|
+
and timestamp. Reading must be within the last 10 minutes.
|
|
47
|
+
"""
|
|
48
|
+
client = get_dexcom_client()
|
|
49
|
+
reading = client.get_current_glucose_reading()
|
|
50
|
+
|
|
51
|
+
if reading is None:
|
|
52
|
+
return {
|
|
53
|
+
"status": "no_data",
|
|
54
|
+
"message": "No current glucose reading available (must be within 10 minutes)"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"glucose_mg_dl": reading.value,
|
|
59
|
+
"glucose_mmol_l": reading.mmol_l,
|
|
60
|
+
"trend": reading.trend_direction,
|
|
61
|
+
"trend_arrow": reading.trend_arrow,
|
|
62
|
+
"trend_description": reading.trend_description,
|
|
63
|
+
"timestamp": reading.datetime.isoformat(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def get_glucose_readings(
|
|
68
|
+
minutes: int = 60,
|
|
69
|
+
max_count: int = 12,
|
|
70
|
+
data: list[dict] | None = None
|
|
71
|
+
) -> dict:
|
|
72
|
+
"""
|
|
73
|
+
Get historical glucose readings.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
minutes: Number of minutes to look back (1-1440, default 60)
|
|
77
|
+
max_count: Maximum readings to return (1-288, default 12)
|
|
78
|
+
data: Optional external readings for persistence layer integration.
|
|
79
|
+
"""
|
|
80
|
+
if data:
|
|
81
|
+
readings = parse_external_data(data)
|
|
82
|
+
# Apply max_count limit
|
|
83
|
+
readings = sorted(readings, key=lambda r: r.datetime, reverse=True)[:max_count]
|
|
84
|
+
else:
|
|
85
|
+
minutes = max(1, min(1440, minutes))
|
|
86
|
+
max_count = max(1, min(288, max_count))
|
|
87
|
+
client = get_dexcom_client()
|
|
88
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=max_count)
|
|
89
|
+
|
|
90
|
+
if not readings:
|
|
91
|
+
return {
|
|
92
|
+
"status": "no_data",
|
|
93
|
+
"message": f"No readings found",
|
|
94
|
+
"readings": []
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"count": len(readings),
|
|
99
|
+
"readings": [
|
|
100
|
+
{
|
|
101
|
+
"glucose_mg_dl": r.value,
|
|
102
|
+
"glucose_mmol_l": getattr(r, 'mmol_l', round(r.value / 18.0, 1)),
|
|
103
|
+
"trend": getattr(r, 'trend_direction', None),
|
|
104
|
+
"trend_arrow": getattr(r, 'trend_arrow', None),
|
|
105
|
+
"timestamp": r.datetime.isoformat(),
|
|
106
|
+
}
|
|
107
|
+
for r in readings
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
def get_statistics(
|
|
113
|
+
minutes: int = 1440,
|
|
114
|
+
low: int = 70,
|
|
115
|
+
high: int = 180,
|
|
116
|
+
data: list[dict] | None = None
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""
|
|
119
|
+
Get glucose statistics for a time period.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
minutes: Number of minutes to analyze (1-1440, default 1440 = 24h)
|
|
123
|
+
low: Low threshold in mg/dL (default 70)
|
|
124
|
+
high: High threshold in mg/dL (default 180)
|
|
125
|
+
data: Optional external readings for persistence layer integration.
|
|
126
|
+
"""
|
|
127
|
+
if data:
|
|
128
|
+
readings = parse_external_data(data)
|
|
129
|
+
else:
|
|
130
|
+
minutes = max(1, min(1440, minutes))
|
|
131
|
+
client = get_dexcom_client()
|
|
132
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=288)
|
|
133
|
+
|
|
134
|
+
if not readings:
|
|
135
|
+
return {
|
|
136
|
+
"status": "no_data",
|
|
137
|
+
"message": "No readings found"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
values = [r.value for r in readings]
|
|
141
|
+
|
|
142
|
+
avg = mean(values)
|
|
143
|
+
sd = stdev(values) if len(values) > 1 else 0.0
|
|
144
|
+
cv = (sd / avg * 100) if avg > 0 else 0.0
|
|
145
|
+
|
|
146
|
+
total = len(values)
|
|
147
|
+
in_range = sum(1 for v in values if low <= v <= high)
|
|
148
|
+
below = sum(1 for v in values if v < low)
|
|
149
|
+
above = sum(1 for v in values if v > high)
|
|
150
|
+
very_low = sum(1 for v in values if v < 54)
|
|
151
|
+
very_high = sum(1 for v in values if v > 250)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"reading_count": total,
|
|
155
|
+
"mean_mg_dl": round(avg, 1),
|
|
156
|
+
"mean_mmol_l": round(avg / 18.0, 1),
|
|
157
|
+
"std_dev": round(sd, 1),
|
|
158
|
+
"cv_percent": round(cv, 1),
|
|
159
|
+
"min_mg_dl": min(values),
|
|
160
|
+
"max_mg_dl": max(values),
|
|
161
|
+
"time_in_range_percent": round(in_range / total * 100, 1),
|
|
162
|
+
"time_below_percent": round(below / total * 100, 1),
|
|
163
|
+
"time_above_percent": round(above / total * 100, 1),
|
|
164
|
+
"time_very_low_percent": round(very_low / total * 100, 1),
|
|
165
|
+
"time_very_high_percent": round(very_high / total * 100, 1),
|
|
166
|
+
"thresholds": {"low": low, "high": high}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@mcp.tool()
|
|
170
|
+
def get_status_summary(minutes: int = 180) -> dict:
|
|
171
|
+
"""
|
|
172
|
+
Get a complete status summary - the "how am I doing?" tool.
|
|
173
|
+
|
|
174
|
+
Returns current glucose, recent trend, stats for the specified period,
|
|
175
|
+
any alerts, and a plain-English summary. Perfect for quick check-ins,
|
|
176
|
+
clinical dashboards, or health intelligence apps.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
minutes: Time period for context stats (1-1440, default 180 = 3 hours)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
- current: Real-time glucose, trend, arrow
|
|
183
|
+
- period_stats: Average, min, max, time-in-range for the period
|
|
184
|
+
- alerts: Any recent lows/highs detected
|
|
185
|
+
- summary: Plain-English interpretation with urgency level
|
|
186
|
+
"""
|
|
187
|
+
minutes = max(1, min(1440, minutes))
|
|
188
|
+
|
|
189
|
+
client = get_dexcom_client()
|
|
190
|
+
|
|
191
|
+
# Current reading
|
|
192
|
+
current = client.get_current_glucose_reading()
|
|
193
|
+
|
|
194
|
+
# Historical readings for context
|
|
195
|
+
max_count = min(288, minutes // 5 + 1)
|
|
196
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=max_count)
|
|
197
|
+
|
|
198
|
+
if current is None and not readings:
|
|
199
|
+
return {
|
|
200
|
+
"status": "no_data",
|
|
201
|
+
"message": "No glucose data available"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
result = {"period_minutes": minutes}
|
|
205
|
+
|
|
206
|
+
# Current state
|
|
207
|
+
if current:
|
|
208
|
+
result["current"] = {
|
|
209
|
+
"glucose_mg_dl": current.value,
|
|
210
|
+
"glucose_mmol_l": current.mmol_l,
|
|
211
|
+
"trend": current.trend_direction,
|
|
212
|
+
"trend_arrow": current.trend_arrow,
|
|
213
|
+
"trend_description": current.trend_description,
|
|
214
|
+
"timestamp": current.datetime.isoformat(),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Period stats
|
|
218
|
+
if readings:
|
|
219
|
+
values = [r.value for r in readings]
|
|
220
|
+
avg = sum(values) / len(values)
|
|
221
|
+
|
|
222
|
+
in_range = sum(1 for v in values if 70 <= v <= 180)
|
|
223
|
+
below = sum(1 for v in values if v < 70)
|
|
224
|
+
above = sum(1 for v in values if v > 180)
|
|
225
|
+
very_low = sum(1 for v in values if v < 54)
|
|
226
|
+
very_high = sum(1 for v in values if v > 250)
|
|
227
|
+
|
|
228
|
+
result["period_stats"] = {
|
|
229
|
+
"average_mg_dl": round(avg, 1),
|
|
230
|
+
"average_mmol_l": round(avg / 18.0, 1),
|
|
231
|
+
"min_mg_dl": min(values),
|
|
232
|
+
"max_mg_dl": max(values),
|
|
233
|
+
"readings_count": len(values),
|
|
234
|
+
"time_in_range_percent": round(in_range / len(values) * 100, 1),
|
|
235
|
+
"time_below_percent": round(below / len(values) * 100, 1),
|
|
236
|
+
"time_above_percent": round(above / len(values) * 100, 1),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# Alerts
|
|
240
|
+
recent_lows = [r for r in readings if r.value < 70]
|
|
241
|
+
recent_highs = [r for r in readings if r.value > 180]
|
|
242
|
+
|
|
243
|
+
result["alerts"] = {
|
|
244
|
+
"has_recent_lows": len(recent_lows) > 0,
|
|
245
|
+
"has_recent_highs": len(recent_highs) > 0,
|
|
246
|
+
"has_urgent_low": very_low > 0,
|
|
247
|
+
"has_urgent_high": very_high > 0,
|
|
248
|
+
"low_count": len(recent_lows),
|
|
249
|
+
"high_count": len(recent_highs),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Plain English summary
|
|
253
|
+
if current:
|
|
254
|
+
glucose = current.value
|
|
255
|
+
|
|
256
|
+
if glucose < 54:
|
|
257
|
+
level = "very low"
|
|
258
|
+
urgency = "urgent"
|
|
259
|
+
elif glucose < 70:
|
|
260
|
+
level = "low"
|
|
261
|
+
urgency = "attention"
|
|
262
|
+
elif glucose <= 180:
|
|
263
|
+
level = "in range"
|
|
264
|
+
urgency = "normal"
|
|
265
|
+
elif glucose <= 250:
|
|
266
|
+
level = "high"
|
|
267
|
+
urgency = "attention"
|
|
268
|
+
else:
|
|
269
|
+
level = "very high"
|
|
270
|
+
urgency = "urgent"
|
|
271
|
+
|
|
272
|
+
# Build summary text
|
|
273
|
+
trend_text = current.trend_description or current.trend_direction
|
|
274
|
+
summary_text = f"Currently {glucose} mg/dL ({level}), trending {trend_text}."
|
|
275
|
+
|
|
276
|
+
if readings:
|
|
277
|
+
tir = result["period_stats"]["time_in_range_percent"]
|
|
278
|
+
hours = minutes / 60
|
|
279
|
+
summary_text += f" Over the last {hours:.1f}h: {tir}% in range."
|
|
280
|
+
|
|
281
|
+
result["summary"] = {
|
|
282
|
+
"text": summary_text,
|
|
283
|
+
"glucose_level": level,
|
|
284
|
+
"urgency": urgency,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
@mcp.tool()
|
|
290
|
+
def detect_episodes(
|
|
291
|
+
minutes: int = 1440,
|
|
292
|
+
low: int = 70,
|
|
293
|
+
high: int = 180,
|
|
294
|
+
data: list[dict] | None = None
|
|
295
|
+
) -> dict:
|
|
296
|
+
"""
|
|
297
|
+
Detect hypoglycemic and hyperglycemic episodes.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
minutes: Time period if using Dexcom API (1-1440, default 1440 = 24h)
|
|
301
|
+
low: Low threshold in mg/dL (default 70)
|
|
302
|
+
high: High threshold in mg/dL (default 180)
|
|
303
|
+
data: Optional external readings for persistence layer integration.
|
|
304
|
+
Schema: [{"glucose_mg_dl": int, "timestamp": "ISO-8601"}, ...]
|
|
305
|
+
"""
|
|
306
|
+
if data:
|
|
307
|
+
readings = parse_external_data(data)
|
|
308
|
+
else:
|
|
309
|
+
minutes = max(1, min(1440, minutes))
|
|
310
|
+
client = get_dexcom_client()
|
|
311
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=288)
|
|
312
|
+
|
|
313
|
+
if not readings:
|
|
314
|
+
return {"status": "no_data", "message": "No readings available"}
|
|
315
|
+
|
|
316
|
+
sorted_readings = sorted(readings, key=lambda r: r.datetime)
|
|
317
|
+
|
|
318
|
+
episodes = []
|
|
319
|
+
current_episode = None
|
|
320
|
+
|
|
321
|
+
for reading in sorted_readings:
|
|
322
|
+
value = reading.value
|
|
323
|
+
|
|
324
|
+
if value < 54:
|
|
325
|
+
episode_type = "very_low"
|
|
326
|
+
elif value < low:
|
|
327
|
+
episode_type = "low"
|
|
328
|
+
elif value > 250:
|
|
329
|
+
episode_type = "very_high"
|
|
330
|
+
elif value > high:
|
|
331
|
+
episode_type = "high"
|
|
332
|
+
else:
|
|
333
|
+
episode_type = None
|
|
334
|
+
|
|
335
|
+
if episode_type:
|
|
336
|
+
is_low = episode_type in ("low", "very_low")
|
|
337
|
+
current_is_low = current_episode and current_episode["type"] in ("low", "very_low")
|
|
338
|
+
|
|
339
|
+
if current_episode and is_low == current_is_low:
|
|
340
|
+
current_episode["end"] = reading.datetime
|
|
341
|
+
current_episode["values"].append(value)
|
|
342
|
+
if episode_type in ("very_low", "very_high"):
|
|
343
|
+
current_episode["type"] = episode_type
|
|
344
|
+
else:
|
|
345
|
+
if current_episode:
|
|
346
|
+
episodes.append(current_episode)
|
|
347
|
+
current_episode = {
|
|
348
|
+
"type": episode_type,
|
|
349
|
+
"start": reading.datetime,
|
|
350
|
+
"end": reading.datetime,
|
|
351
|
+
"values": [value],
|
|
352
|
+
}
|
|
353
|
+
else:
|
|
354
|
+
if current_episode:
|
|
355
|
+
episodes.append(current_episode)
|
|
356
|
+
current_episode = None
|
|
357
|
+
|
|
358
|
+
if current_episode:
|
|
359
|
+
current_episode["ongoing"] = True
|
|
360
|
+
episodes.append(current_episode)
|
|
361
|
+
|
|
362
|
+
formatted = []
|
|
363
|
+
for ep in episodes:
|
|
364
|
+
duration = int((ep["end"] - ep["start"]).total_seconds() / 60)
|
|
365
|
+
values = ep["values"]
|
|
366
|
+
extreme = min(values) if ep["type"] in ("low", "very_low") else max(values)
|
|
367
|
+
|
|
368
|
+
formatted.append({
|
|
369
|
+
"type": ep["type"],
|
|
370
|
+
"start": ep["start"].isoformat(),
|
|
371
|
+
"end": ep["end"].isoformat(),
|
|
372
|
+
"duration_minutes": max(duration, 5),
|
|
373
|
+
"extreme_value": extreme,
|
|
374
|
+
"mean_value": round(sum(values) / len(values), 1),
|
|
375
|
+
"ongoing": ep.get("ongoing", False),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
low_eps = [e for e in formatted if e["type"] in ("low", "very_low")]
|
|
379
|
+
high_eps = [e for e in formatted if e["type"] in ("high", "very_high")]
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
"readings_analyzed": len(sorted_readings),
|
|
383
|
+
"episodes": formatted,
|
|
384
|
+
"summary": {
|
|
385
|
+
"total_episodes": len(formatted),
|
|
386
|
+
"low_episodes": len(low_eps),
|
|
387
|
+
"high_episodes": len(high_eps),
|
|
388
|
+
"total_low_minutes": sum(e["duration_minutes"] for e in low_eps),
|
|
389
|
+
"total_high_minutes": sum(e["duration_minutes"] for e in high_eps),
|
|
390
|
+
"severe_lows": sum(1 for e in low_eps if e["type"] == "very_low"),
|
|
391
|
+
"severe_highs": sum(1 for e in high_eps if e["type"] == "very_high"),
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
@mcp.tool()
|
|
396
|
+
def get_episode_details(
|
|
397
|
+
minutes: int = 1440,
|
|
398
|
+
low: int = 70,
|
|
399
|
+
high: int = 180,
|
|
400
|
+
data: list[dict] | None = None
|
|
401
|
+
) -> dict:
|
|
402
|
+
"""
|
|
403
|
+
Get detailed context for each glucose episode - what led to it,
|
|
404
|
+
how severe it was, and how recovery went.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
minutes: Time period if using Dexcom API (1-1440, default 1440 = 24h)
|
|
408
|
+
low: Low threshold in mg/dL (default 70)
|
|
409
|
+
high: High threshold in mg/dL (default 180)
|
|
410
|
+
data: Optional external readings for persistence layer integration.
|
|
411
|
+
"""
|
|
412
|
+
if data:
|
|
413
|
+
readings = parse_external_data(data)
|
|
414
|
+
else:
|
|
415
|
+
minutes = max(1, min(1440, minutes))
|
|
416
|
+
client = get_dexcom_client()
|
|
417
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=288)
|
|
418
|
+
|
|
419
|
+
if not readings:
|
|
420
|
+
return {"status": "no_data", "message": "No readings available"}
|
|
421
|
+
|
|
422
|
+
points = sorted([(r.value, r.datetime) for r in readings], key=lambda x: x[1])
|
|
423
|
+
|
|
424
|
+
# Find episodes
|
|
425
|
+
episodes = []
|
|
426
|
+
i = 0
|
|
427
|
+
while i < len(points):
|
|
428
|
+
value, dt = points[i]
|
|
429
|
+
|
|
430
|
+
if value < low:
|
|
431
|
+
ep_type = "very_low" if value < 54 else "low"
|
|
432
|
+
is_low_episode = True
|
|
433
|
+
elif value > high:
|
|
434
|
+
ep_type = "very_high" if value > 250 else "high"
|
|
435
|
+
is_low_episode = False
|
|
436
|
+
else:
|
|
437
|
+
i += 1
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
start_idx = i
|
|
441
|
+
episode_values = []
|
|
442
|
+
while i < len(points):
|
|
443
|
+
v = points[i][0]
|
|
444
|
+
in_low = v < low
|
|
445
|
+
in_high = v > high
|
|
446
|
+
|
|
447
|
+
if (is_low_episode and not in_low) or (not is_low_episode and not in_high):
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
episode_values.append(points[i])
|
|
451
|
+
if v < 54:
|
|
452
|
+
ep_type = "very_low"
|
|
453
|
+
elif v > 250:
|
|
454
|
+
ep_type = "very_high"
|
|
455
|
+
i += 1
|
|
456
|
+
|
|
457
|
+
episodes.append({
|
|
458
|
+
"type": ep_type,
|
|
459
|
+
"start_idx": start_idx,
|
|
460
|
+
"end_idx": i - 1,
|
|
461
|
+
"values": episode_values,
|
|
462
|
+
"is_low": is_low_episode,
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
# Analyze each episode
|
|
466
|
+
detailed = []
|
|
467
|
+
for ep in episodes:
|
|
468
|
+
values = ep["values"]
|
|
469
|
+
start_idx, end_idx = ep["start_idx"], ep["end_idx"]
|
|
470
|
+
start_time, end_time = values[0][1], values[-1][1]
|
|
471
|
+
glucose_values = [v for v, _ in values]
|
|
472
|
+
|
|
473
|
+
# Find extreme point (nadir for lows, peak for highs)
|
|
474
|
+
if ep["is_low"]:
|
|
475
|
+
extreme = min(glucose_values)
|
|
476
|
+
extreme_idx = glucose_values.index(extreme)
|
|
477
|
+
else:
|
|
478
|
+
extreme = max(glucose_values)
|
|
479
|
+
extreme_idx = glucose_values.index(extreme)
|
|
480
|
+
|
|
481
|
+
extreme_time = values[extreme_idx][1]
|
|
482
|
+
duration = max(int((end_time - start_time).total_seconds() / 60), 5)
|
|
483
|
+
|
|
484
|
+
# Lead-up (30 min before episode)
|
|
485
|
+
leadup = points[max(0, start_idx - 6):start_idx]
|
|
486
|
+
|
|
487
|
+
# Rate TO extreme (how fast did you spike/drop?)
|
|
488
|
+
rate_to_extreme = None
|
|
489
|
+
if extreme_idx > 0:
|
|
490
|
+
t_diff = (extreme_time - start_time).total_seconds() / 60
|
|
491
|
+
if t_diff > 0:
|
|
492
|
+
v_diff = extreme - values[0][0]
|
|
493
|
+
rate_to_extreme = round(v_diff / t_diff * 5, 1) # per 5 min
|
|
494
|
+
|
|
495
|
+
# Rate FROM extreme (how fast did you recover within episode?)
|
|
496
|
+
rate_from_extreme = None
|
|
497
|
+
if extreme_idx < len(values) - 1:
|
|
498
|
+
t_diff = (end_time - extreme_time).total_seconds() / 60
|
|
499
|
+
if t_diff > 0:
|
|
500
|
+
v_diff = values[-1][0] - extreme
|
|
501
|
+
rate_from_extreme = round(v_diff / t_diff * 5, 1) # per 5 min
|
|
502
|
+
|
|
503
|
+
# Recovery after episode (30 min after)
|
|
504
|
+
recovery = points[end_idx + 1:min(len(points), end_idx + 7)]
|
|
505
|
+
recovery_minutes = None
|
|
506
|
+
recovery_rate = None
|
|
507
|
+
overcorrection = None
|
|
508
|
+
|
|
509
|
+
if recovery:
|
|
510
|
+
# Time to get back in range
|
|
511
|
+
for v, dt in recovery:
|
|
512
|
+
if low <= v <= high:
|
|
513
|
+
recovery_minutes = int((dt - end_time).total_seconds() / 60)
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
# Recovery rate after episode ended
|
|
517
|
+
t_diff = (recovery[-1][1] - end_time).total_seconds() / 60
|
|
518
|
+
if t_diff > 0:
|
|
519
|
+
recovery_rate = round((recovery[-1][0] - values[-1][0]) / t_diff * 5, 1)
|
|
520
|
+
|
|
521
|
+
# Overcorrection check
|
|
522
|
+
recovery_values = [v for v, _ in recovery]
|
|
523
|
+
if ep["is_low"] and max(recovery_values) > high:
|
|
524
|
+
overcorrection = {"type": "rebound_high", "value": max(recovery_values)}
|
|
525
|
+
elif not ep["is_low"] and min(recovery_values) < low:
|
|
526
|
+
overcorrection = {"type": "overcorrect_low", "value": min(recovery_values)}
|
|
527
|
+
|
|
528
|
+
detailed.append({
|
|
529
|
+
"type": ep["type"],
|
|
530
|
+
"start": start_time.isoformat(),
|
|
531
|
+
"end": end_time.isoformat(),
|
|
532
|
+
"duration_minutes": duration,
|
|
533
|
+
"extreme_value": extreme,
|
|
534
|
+
"extreme_time": extreme_time.isoformat(),
|
|
535
|
+
"rate_to_extreme_per_5min": rate_to_extreme,
|
|
536
|
+
"rate_from_extreme_per_5min": rate_from_extreme,
|
|
537
|
+
"recovery_minutes": recovery_minutes,
|
|
538
|
+
"recovery_rate_per_5min": recovery_rate,
|
|
539
|
+
"overcorrection": overcorrection,
|
|
540
|
+
"leadup_values": [v for v, _ in leadup],
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
"readings_analyzed": len(points),
|
|
545
|
+
"episodes_analyzed": len(detailed),
|
|
546
|
+
"episodes": detailed,
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
@mcp.tool()
|
|
550
|
+
def analyze_time_blocks(
|
|
551
|
+
minutes: int = 1440,
|
|
552
|
+
low: int = 70,
|
|
553
|
+
high: int = 180,
|
|
554
|
+
data: list[dict] | None = None
|
|
555
|
+
) -> dict:
|
|
556
|
+
"""
|
|
557
|
+
Analyze glucose by time of day - find when problems happen.
|
|
558
|
+
|
|
559
|
+
Breaks down data into overnight (00-06), morning (06-12),
|
|
560
|
+
afternoon (12-18), and evening (18-24).
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
minutes: Time period if using Dexcom API (1-1440, default 1440 = 24h)
|
|
564
|
+
low: Low threshold in mg/dL (default 70)
|
|
565
|
+
high: High threshold in mg/dL (default 180)
|
|
566
|
+
data: Optional external readings for persistence layer integration.
|
|
567
|
+
"""
|
|
568
|
+
if data:
|
|
569
|
+
readings = parse_external_data(data)
|
|
570
|
+
else:
|
|
571
|
+
minutes = max(1, min(1440, minutes))
|
|
572
|
+
client = get_dexcom_client()
|
|
573
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=288)
|
|
574
|
+
|
|
575
|
+
if not readings:
|
|
576
|
+
return {"status": "no_data", "message": "No readings available"}
|
|
577
|
+
|
|
578
|
+
# Define time blocks
|
|
579
|
+
blocks = {
|
|
580
|
+
"overnight": {"range": "00:00-06:00", "readings": []},
|
|
581
|
+
"morning": {"range": "06:00-12:00", "readings": []},
|
|
582
|
+
"afternoon": {"range": "12:00-18:00", "readings": []},
|
|
583
|
+
"evening": {"range": "18:00-24:00", "readings": []},
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
# Sort readings into blocks
|
|
587
|
+
for r in readings:
|
|
588
|
+
hour = r.datetime.hour
|
|
589
|
+
value = r.value
|
|
590
|
+
|
|
591
|
+
if 0 <= hour < 6:
|
|
592
|
+
blocks["overnight"]["readings"].append(value)
|
|
593
|
+
elif 6 <= hour < 12:
|
|
594
|
+
blocks["morning"]["readings"].append(value)
|
|
595
|
+
elif 12 <= hour < 18:
|
|
596
|
+
blocks["afternoon"]["readings"].append(value)
|
|
597
|
+
else:
|
|
598
|
+
blocks["evening"]["readings"].append(value)
|
|
599
|
+
|
|
600
|
+
# Analyze each block
|
|
601
|
+
analyzed = {}
|
|
602
|
+
best_block = None
|
|
603
|
+
worst_block = None
|
|
604
|
+
best_tir = -1
|
|
605
|
+
worst_tir = 101
|
|
606
|
+
|
|
607
|
+
for name, block in blocks.items():
|
|
608
|
+
values = block["readings"]
|
|
609
|
+
|
|
610
|
+
if not values:
|
|
611
|
+
analyzed[name] = {
|
|
612
|
+
"time_range": block["range"],
|
|
613
|
+
"status": "no_data",
|
|
614
|
+
"readings_count": 0,
|
|
615
|
+
}
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
avg = sum(values) / len(values)
|
|
619
|
+
in_range = sum(1 for v in values if low <= v <= high)
|
|
620
|
+
below = sum(1 for v in values if v < low)
|
|
621
|
+
above = sum(1 for v in values if v > high)
|
|
622
|
+
tir = round(in_range / len(values) * 100, 1)
|
|
623
|
+
|
|
624
|
+
# Track best/worst
|
|
625
|
+
if tir > best_tir:
|
|
626
|
+
best_tir = tir
|
|
627
|
+
best_block = name
|
|
628
|
+
if tir < worst_tir:
|
|
629
|
+
worst_tir = tir
|
|
630
|
+
worst_block = name
|
|
631
|
+
|
|
632
|
+
# Assessment
|
|
633
|
+
if tir >= 80:
|
|
634
|
+
assessment = "excellent"
|
|
635
|
+
elif tir >= 70:
|
|
636
|
+
assessment = "good"
|
|
637
|
+
elif tir >= 50:
|
|
638
|
+
assessment = "needs attention"
|
|
639
|
+
else:
|
|
640
|
+
assessment = "problematic"
|
|
641
|
+
|
|
642
|
+
analyzed[name] = {
|
|
643
|
+
"time_range": block["range"],
|
|
644
|
+
"readings_count": len(values),
|
|
645
|
+
"average_mg_dl": round(avg, 1),
|
|
646
|
+
"min_mg_dl": min(values),
|
|
647
|
+
"max_mg_dl": max(values),
|
|
648
|
+
"time_in_range_percent": tir,
|
|
649
|
+
"time_below_percent": round(below / len(values) * 100, 1),
|
|
650
|
+
"time_above_percent": round(above / len(values) * 100, 1),
|
|
651
|
+
"assessment": assessment,
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
"readings_analyzed": len(readings),
|
|
656
|
+
"blocks": analyzed,
|
|
657
|
+
"best_block": best_block,
|
|
658
|
+
"worst_block": worst_block,
|
|
659
|
+
"insight": f"Best control during {best_block} ({best_tir}% TIR), worst during {worst_block} ({worst_tir}% TIR)" if best_block and worst_block else None,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
@mcp.tool()
|
|
663
|
+
def check_alerts(
|
|
664
|
+
urgent_low: int = 54,
|
|
665
|
+
low: int = 70,
|
|
666
|
+
high: int = 180,
|
|
667
|
+
urgent_high: int = 250
|
|
668
|
+
) -> dict:
|
|
669
|
+
"""
|
|
670
|
+
Check current glucose against alert thresholds.
|
|
671
|
+
|
|
672
|
+
Simple threshold check for real-time alerting.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
urgent_low: Urgent low threshold (default 54)
|
|
676
|
+
low: Low threshold (default 70)
|
|
677
|
+
high: High threshold (default 180)
|
|
678
|
+
urgent_high: Urgent high threshold (default 250)
|
|
679
|
+
"""
|
|
680
|
+
client = get_dexcom_client()
|
|
681
|
+
reading = client.get_current_glucose_reading()
|
|
682
|
+
|
|
683
|
+
if not reading:
|
|
684
|
+
return {
|
|
685
|
+
"status": "no_data",
|
|
686
|
+
"message": "No current reading available",
|
|
687
|
+
"alerts": [],
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
value = reading.value
|
|
691
|
+
trend = reading.trend_direction
|
|
692
|
+
alerts = []
|
|
693
|
+
|
|
694
|
+
# Check thresholds
|
|
695
|
+
if value < urgent_low:
|
|
696
|
+
alerts.append({"level": "urgent", "type": "very_low", "message": f"Urgent low: {value} mg/dL"})
|
|
697
|
+
elif value < low:
|
|
698
|
+
alerts.append({"level": "warning", "type": "low", "message": f"Low: {value} mg/dL"})
|
|
699
|
+
elif value > urgent_high:
|
|
700
|
+
alerts.append({"level": "urgent", "type": "very_high", "message": f"Urgent high: {value} mg/dL"})
|
|
701
|
+
elif value > high:
|
|
702
|
+
alerts.append({"level": "warning", "type": "high", "message": f"High: {value} mg/dL"})
|
|
703
|
+
|
|
704
|
+
# Trend alerts
|
|
705
|
+
if trend in ("SingleDown", "DoubleDown") and value < 100:
|
|
706
|
+
alerts.append({"level": "warning", "type": "falling_fast", "message": f"Falling fast at {value} mg/dL"})
|
|
707
|
+
elif trend in ("SingleUp", "DoubleUp") and value > 150:
|
|
708
|
+
alerts.append({"level": "warning", "type": "rising_fast", "message": f"Rising fast at {value} mg/dL"})
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
"current_glucose": value,
|
|
712
|
+
"trend": trend,
|
|
713
|
+
"trend_arrow": reading.trend_arrow,
|
|
714
|
+
"timestamp": reading.datetime.isoformat(),
|
|
715
|
+
"has_alerts": len(alerts) > 0,
|
|
716
|
+
"alert_count": len(alerts),
|
|
717
|
+
"alerts": alerts,
|
|
718
|
+
"status": "alert" if alerts else "ok",
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
@mcp.tool()
|
|
722
|
+
def export_data(
|
|
723
|
+
minutes: int = 1440,
|
|
724
|
+
format: str = "json",
|
|
725
|
+
data: list[dict] | None = None
|
|
726
|
+
) -> dict:
|
|
727
|
+
"""
|
|
728
|
+
Export glucose readings for persistence layer integration.
|
|
729
|
+
|
|
730
|
+
Returns clean, consistent data structure for storage in external databases.
|
|
731
|
+
Call periodically to build long-term data history.
|
|
732
|
+
|
|
733
|
+
Args:
|
|
734
|
+
minutes: Time period to export (1-1440, default 1440 = 24h)
|
|
735
|
+
format: Export format - "json" or "csv" (default "json")
|
|
736
|
+
data: Optional external readings to format/export instead of fetching.
|
|
737
|
+
"""
|
|
738
|
+
if data:
|
|
739
|
+
readings = parse_external_data(data)
|
|
740
|
+
else:
|
|
741
|
+
minutes = max(1, min(1440, minutes))
|
|
742
|
+
client = get_dexcom_client()
|
|
743
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=288)
|
|
744
|
+
|
|
745
|
+
if not readings:
|
|
746
|
+
return {"status": "no_data", "message": "No readings to export"}
|
|
747
|
+
|
|
748
|
+
# Consistent schema for persistence
|
|
749
|
+
records = [
|
|
750
|
+
{
|
|
751
|
+
"glucose_mg_dl": r.value,
|
|
752
|
+
"glucose_mmol_l": getattr(r, 'mmol_l', round(r.value / 18.0, 1)),
|
|
753
|
+
"trend": getattr(r, 'trend_direction', None),
|
|
754
|
+
"trend_arrow": getattr(r, 'trend_arrow', None),
|
|
755
|
+
"timestamp": r.datetime.isoformat(),
|
|
756
|
+
}
|
|
757
|
+
for r in readings
|
|
758
|
+
]
|
|
759
|
+
|
|
760
|
+
from datetime import datetime
|
|
761
|
+
|
|
762
|
+
result = {
|
|
763
|
+
"export_timestamp": datetime.now().isoformat(),
|
|
764
|
+
"readings_count": len(records),
|
|
765
|
+
"period_minutes": minutes if not data else None,
|
|
766
|
+
"oldest_reading": records[-1]["timestamp"] if records else None,
|
|
767
|
+
"newest_reading": records[0]["timestamp"] if records else None,
|
|
768
|
+
"format": format,
|
|
769
|
+
"readings": records,
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if format == "csv":
|
|
773
|
+
headers = ["timestamp", "glucose_mg_dl", "glucose_mmol_l", "trend", "trend_arrow"]
|
|
774
|
+
csv_rows = [",".join(headers)]
|
|
775
|
+
for r in records:
|
|
776
|
+
csv_rows.append(f"{r['timestamp']},{r['glucose_mg_dl']},{r['glucose_mmol_l']},{r['trend']},{r['trend_arrow']}")
|
|
777
|
+
result["csv"] = "\n".join(csv_rows)
|
|
778
|
+
|
|
779
|
+
return result
|
|
780
|
+
|
|
781
|
+
@mcp.tool()
|
|
782
|
+
def get_agp_report(
|
|
783
|
+
minutes: int = 1440,
|
|
784
|
+
data: list[dict] | None = None
|
|
785
|
+
) -> dict:
|
|
786
|
+
"""
|
|
787
|
+
Generate an Ambulatory Glucose Profile (AGP) report.
|
|
788
|
+
|
|
789
|
+
AGP is the clinical standard used by endocrinologists. Shows glucose
|
|
790
|
+
percentiles by time of day to identify patterns.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
minutes: Time period if using Dexcom API (1-1440, default 1440 = 24h)
|
|
794
|
+
data: Optional external readings for persistence layer integration.
|
|
795
|
+
"""
|
|
796
|
+
if data:
|
|
797
|
+
readings = parse_external_data(data)
|
|
798
|
+
else:
|
|
799
|
+
minutes = max(1, min(1440, minutes))
|
|
800
|
+
client = get_dexcom_client()
|
|
801
|
+
readings = client.get_glucose_readings(minutes=minutes, max_count=288)
|
|
802
|
+
|
|
803
|
+
if not readings:
|
|
804
|
+
return {"status": "no_data", "message": "No readings available"}
|
|
805
|
+
|
|
806
|
+
# Group readings by hour
|
|
807
|
+
hourly_values = {h: [] for h in range(24)}
|
|
808
|
+
for r in readings:
|
|
809
|
+
hourly_values[r.datetime.hour].append(r.value)
|
|
810
|
+
|
|
811
|
+
def percentile(values: list, p: int) -> int | None:
|
|
812
|
+
if not values:
|
|
813
|
+
return None
|
|
814
|
+
sorted_vals = sorted(values)
|
|
815
|
+
idx = int(len(sorted_vals) * p / 100)
|
|
816
|
+
idx = min(idx, len(sorted_vals) - 1)
|
|
817
|
+
return sorted_vals[idx]
|
|
818
|
+
|
|
819
|
+
# Build hourly profile with percentiles
|
|
820
|
+
hourly_profile = []
|
|
821
|
+
for hour in range(24):
|
|
822
|
+
values = hourly_values[hour]
|
|
823
|
+
if values:
|
|
824
|
+
hourly_profile.append({
|
|
825
|
+
"hour": hour,
|
|
826
|
+
"p5": percentile(values, 5),
|
|
827
|
+
"p25": percentile(values, 25),
|
|
828
|
+
"p50": percentile(values, 50),
|
|
829
|
+
"p75": percentile(values, 75),
|
|
830
|
+
"p95": percentile(values, 95),
|
|
831
|
+
"readings_count": len(values),
|
|
832
|
+
})
|
|
833
|
+
else:
|
|
834
|
+
hourly_profile.append({
|
|
835
|
+
"hour": hour,
|
|
836
|
+
"p5": None, "p25": None, "p50": None, "p75": None, "p95": None,
|
|
837
|
+
"readings_count": 0,
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
# Overall stats
|
|
841
|
+
all_values = [r.value for r in readings]
|
|
842
|
+
avg = sum(all_values) / len(all_values)
|
|
843
|
+
sd = stdev(all_values) if len(all_values) > 1 else 0
|
|
844
|
+
cv = (sd / avg * 100) if avg > 0 else 0
|
|
845
|
+
gmi = 3.31 + 0.02392 * avg
|
|
846
|
+
|
|
847
|
+
in_range = sum(1 for v in all_values if 70 <= v <= 180)
|
|
848
|
+
below_70 = sum(1 for v in all_values if v < 70)
|
|
849
|
+
below_54 = sum(1 for v in all_values if v < 54)
|
|
850
|
+
above_180 = sum(1 for v in all_values if v > 180)
|
|
851
|
+
above_250 = sum(1 for v in all_values if v > 250)
|
|
852
|
+
total = len(all_values)
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
"report_type": "ambulatory_glucose_profile",
|
|
856
|
+
"period_minutes": minutes,
|
|
857
|
+
"readings_analyzed": total,
|
|
858
|
+
|
|
859
|
+
"glucose_metrics": {
|
|
860
|
+
"mean_mg_dl": round(avg, 1),
|
|
861
|
+
"gmi_percent": round(gmi, 1),
|
|
862
|
+
"cv_percent": round(cv, 1),
|
|
863
|
+
"std_dev": round(sd, 1),
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
"time_in_ranges": {
|
|
867
|
+
"very_low_below_54": round(below_54 / total * 100, 1),
|
|
868
|
+
"low_54_70": round((below_70 - below_54) / total * 100, 1),
|
|
869
|
+
"target_70_180": round(in_range / total * 100, 1),
|
|
870
|
+
"high_180_250": round((above_180 - above_250) / total * 100, 1),
|
|
871
|
+
"very_high_above_250": round(above_250 / total * 100, 1),
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
"clinical_targets": {
|
|
875
|
+
"tir_target": ">70%",
|
|
876
|
+
"tir_actual": round(in_range / total * 100, 1),
|
|
877
|
+
"tbr_target": "<4%",
|
|
878
|
+
"tbr_actual": round(below_70 / total * 100, 1),
|
|
879
|
+
"cv_target": "<36%",
|
|
880
|
+
"cv_actual": round(cv, 1),
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
"hourly_profile": hourly_profile,
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
def main():
|
|
887
|
+
mcp.run()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
if __name__ == "__main__":
|
|
891
|
+
main()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-server-dexcom-health
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Dexcom CGM glucose data - continuous health intelligence for AI agents
|
|
5
|
+
Author-email: Ishaan Sharma <ishaan02@hotmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai-agents,cgm,dexcom,diabetes,glucose,health,mcp,model-context-protocol
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: mcp[cli]>=1.26.0
|
|
18
|
+
Requires-Dist: pydantic>=2.12.5
|
|
19
|
+
Requires-Dist: pydexcom>=0.5.1
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# mcp-server-dexcom
|
|
23
|
+
|
|
24
|
+
MCP server for Dexcom CGM glucose data. Enables AI agents to access and analyze continuous glucose monitor data for health intelligence applications.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Real-time glucose monitoring** - Current readings with trend analysis
|
|
29
|
+
- **Historical data access** - Up to 24 hours of glucose history
|
|
30
|
+
- **Clinical analytics** - Time-in-range, GMI, CV%, AGP reports
|
|
31
|
+
- **Episode detection** - Automatic hypo/hyper event identification with detailed context
|
|
32
|
+
- **Time-block analysis** - Identify patterns by time of day
|
|
33
|
+
- **Persistence layer support** - Pass external data for long-term analysis
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
| Tool | Description |
|
|
38
|
+
|------|-------------|
|
|
39
|
+
| `get_current_glucose` | Current glucose reading with trend |
|
|
40
|
+
| `get_glucose_readings` | Historical readings (up to 24h) |
|
|
41
|
+
| `get_statistics` | TIR, CV%, GMI, and other metrics |
|
|
42
|
+
| `get_status_summary` | Complete "how am I doing?" summary |
|
|
43
|
+
| `detect_episodes` | Find hypo/hyper episodes |
|
|
44
|
+
| `get_episode_details` | Deep analysis of each episode |
|
|
45
|
+
| `analyze_time_blocks` | Patterns by time of day |
|
|
46
|
+
| `check_alerts` | Real-time threshold alerts |
|
|
47
|
+
| `export_data` | Export for external storage |
|
|
48
|
+
| `get_agp_report` | Clinical AGP report |
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
```bash
|
|
52
|
+
# Using uvx (recommended)
|
|
53
|
+
uvx mcp-server-dexcom
|
|
54
|
+
|
|
55
|
+
# Using pip
|
|
56
|
+
pip install mcp-server-dexcom
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Set environment variables:
|
|
62
|
+
|
|
63
|
+
| Variable | Required | Description |
|
|
64
|
+
|----------|----------|-------------|
|
|
65
|
+
| `DEXCOM_USERNAME` | Yes | Dexcom username, email, or phone (+1234567890) |
|
|
66
|
+
| `DEXCOM_PASSWORD` | Yes | Dexcom password |
|
|
67
|
+
| `DEXCOM_REGION` | No | `us` (default), `ous` (outside US), or `jp` (Japan) |
|
|
68
|
+
|
|
69
|
+
### Claude Desktop
|
|
70
|
+
|
|
71
|
+
Add to your `claude_desktop_config.json`:
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"dexcom": {
|
|
76
|
+
"command": "uvx",
|
|
77
|
+
"args": ["mcp-server-dexcom"],
|
|
78
|
+
"env": {
|
|
79
|
+
"DEXCOM_USERNAME": "your_username",
|
|
80
|
+
"DEXCOM_PASSWORD": "your_password",
|
|
81
|
+
"DEXCOM_REGION": "us"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Usage Examples
|
|
89
|
+
|
|
90
|
+
### Basic usage with Claude
|
|
91
|
+
|
|
92
|
+
> "What's my current glucose?"
|
|
93
|
+
|
|
94
|
+
> "How was my overnight control?"
|
|
95
|
+
|
|
96
|
+
> "Did I have any lows today?"
|
|
97
|
+
|
|
98
|
+
> "Give me my statistics for the last 12 hours"
|
|
99
|
+
|
|
100
|
+
### Persistence Layer Integration
|
|
101
|
+
|
|
102
|
+
Tools that analyze data accept an optional `data` parameter for external data sources:
|
|
103
|
+
```python
|
|
104
|
+
# Pass your own historical data
|
|
105
|
+
result = get_statistics(
|
|
106
|
+
data=[
|
|
107
|
+
{"glucose_mg_dl": 120, "timestamp": "2024-01-15T08:00:00"},
|
|
108
|
+
{"glucose_mg_dl": 135, "timestamp": "2024-01-15T08:05:00"},
|
|
109
|
+
# ... more readings
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This enables building long-term analytics by storing data externally and passing it back for analysis.
|
|
115
|
+
|
|
116
|
+
## Requirements
|
|
117
|
+
|
|
118
|
+
- Python 3.10+
|
|
119
|
+
- Active Dexcom Share session (requires Dexcom mobile app with Share enabled)
|
|
120
|
+
- At least one follower configured in Dexcom Share
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## **3. Create `LICENSE`:**
|
|
130
|
+
```
|
|
131
|
+
MIT License
|
|
132
|
+
|
|
133
|
+
Copyright (c) 2024
|
|
134
|
+
|
|
135
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
136
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
137
|
+
in the Software without restriction, including without limitation the rights
|
|
138
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
139
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
140
|
+
furnished to do so, subject to the following conditions:
|
|
141
|
+
|
|
142
|
+
The above copyright notice and this permission notice shall be included in all
|
|
143
|
+
copies or substantial portions of the Software.
|
|
144
|
+
|
|
145
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
146
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
147
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
148
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
149
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
150
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
151
|
+
SOFTWARE.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## **4. Create `.gitignore`:**
|
|
157
|
+
```
|
|
158
|
+
__pycache__/
|
|
159
|
+
*.py[cod]
|
|
160
|
+
*$py.class
|
|
161
|
+
.env
|
|
162
|
+
.venv/
|
|
163
|
+
dist/
|
|
164
|
+
build/
|
|
165
|
+
*.egg-info/
|
|
166
|
+
.mypy_cache/
|
|
167
|
+
.ruff_cache/
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## **5. Final project structure:**
|
|
173
|
+
```
|
|
174
|
+
mcp-server-dexcom/
|
|
175
|
+
├── pyproject.toml
|
|
176
|
+
├── README.md
|
|
177
|
+
├── LICENSE
|
|
178
|
+
├── .gitignore
|
|
179
|
+
├── .env # (not committed)
|
|
180
|
+
└── src/
|
|
181
|
+
└── mcp_server_dexcom/
|
|
182
|
+
├── __init__.py
|
|
183
|
+
└── server.py
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
mcp_server_dexcom/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mcp_server_dexcom/server.py,sha256=243H0sgsj8CbXRH0NGpQM1IbKpE4LpORI7A8nXeTFv0,30355
|
|
3
|
+
mcp_server_dexcom_health-0.1.0.dist-info/METADATA,sha256=FQbU_8Y_6FsWmfb0Qnzo_tmPheFwjLnC_jqrEdF3iuU,5242
|
|
4
|
+
mcp_server_dexcom_health-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
mcp_server_dexcom_health-0.1.0.dist-info/entry_points.txt,sha256=CSZitISuir7U9fnYVaXuT7zNUQlQn0XSaT64zayYkq4,75
|
|
6
|
+
mcp_server_dexcom_health-0.1.0.dist-info/RECORD,,
|