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.
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-server-dexcom-health = mcp_server_dexcom.server:main