cronometer-api-mcp 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.
@@ -0,0 +1,1027 @@
1
+ """MCP server for Cronometer nutrition data via the mobile REST API."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from datetime import date, timedelta
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from .client import CronometerClient
11
+
12
+ logging.basicConfig(level=logging.INFO)
13
+ logger = logging.getLogger(__name__)
14
+
15
+ mcp = FastMCP(
16
+ "cronometer",
17
+ instructions=(
18
+ "Cronometer MCP server for nutrition tracking via the mobile REST API. "
19
+ "Provides access to food search, diary management, daily nutrition data, "
20
+ "macro targets, biometrics, and fasting history from Cronometer. "
21
+ "Use search_foods to find foods, get_food_details for nutrition info "
22
+ "and serving sizes, add_food_entry to log meals, and get_food_log to "
23
+ "review what was eaten."
24
+ ),
25
+ )
26
+
27
+ _client: CronometerClient | None = None
28
+
29
+
30
+ def _get_client() -> CronometerClient:
31
+ global _client
32
+ if _client is None:
33
+ _client = CronometerClient()
34
+ return _client
35
+
36
+
37
+ def _parse_date(d: str | None) -> date | None:
38
+ if d is None:
39
+ return None
40
+ return date.fromisoformat(d)
41
+
42
+
43
+ def _ok(data: dict) -> str:
44
+ """Wrap a successful response."""
45
+ return json.dumps({"status": "success", **data}, indent=2)
46
+
47
+
48
+ def _err(e: Exception) -> str:
49
+ """Wrap an error response with actionable messages."""
50
+ import httpx
51
+
52
+ if isinstance(e, httpx.HTTPStatusError):
53
+ status = e.response.status_code
54
+ if status == 401 or status == 403:
55
+ msg = "Authentication failed. Cronometer session may have expired -- try again."
56
+ elif status == 429:
57
+ msg = "Rate limit exceeded. Wait a few minutes before retrying."
58
+ elif status == 404:
59
+ msg = f"Resource not found (HTTP {status})."
60
+ else:
61
+ msg = f"Cronometer API error (HTTP {status})."
62
+ elif isinstance(e, httpx.TimeoutException):
63
+ msg = "Request timed out. Cronometer may be slow -- try again."
64
+ elif isinstance(e, httpx.ConnectError):
65
+ msg = "Could not connect to Cronometer. Check network connectivity."
66
+ else:
67
+ msg = f"{type(e).__name__}: {e}"
68
+
69
+ return json.dumps({"status": "error", "message": msg})
70
+
71
+
72
+ # ------------------------------------------------------------------
73
+ # Diary: read
74
+ # ------------------------------------------------------------------
75
+
76
+
77
+ @mcp.tool(
78
+ annotations={
79
+ "readOnlyHint": True,
80
+ "destructiveHint": False,
81
+ "idempotentHint": True,
82
+ "openWorldHint": True,
83
+ }
84
+ )
85
+ def get_food_log(date: str | None = None) -> str:
86
+ """Get all diary entries for a given date.
87
+
88
+ Returns every food entry logged for the day, including food names,
89
+ amounts, meal groups, and nutrient data.
90
+
91
+ Args:
92
+ date: Date as YYYY-MM-DD (defaults to today).
93
+ """
94
+ try:
95
+ client = _get_client()
96
+ day = _parse_date(date)
97
+ data = client.get_diary(day)
98
+ return _ok(
99
+ {
100
+ "date": date or str(date_module_today()),
101
+ "diary": data,
102
+ }
103
+ )
104
+ except Exception as e:
105
+ return _err(e)
106
+
107
+
108
+ # ------------------------------------------------------------------
109
+ # Diary: write
110
+ # ------------------------------------------------------------------
111
+
112
+
113
+ @mcp.tool(
114
+ annotations={
115
+ "readOnlyHint": False,
116
+ "destructiveHint": False,
117
+ "idempotentHint": False,
118
+ "openWorldHint": True,
119
+ }
120
+ )
121
+ def add_food_entry(
122
+ food_id: int,
123
+ measure_id: int,
124
+ grams: float,
125
+ date: str | None = None,
126
+ translation_id: int = 0,
127
+ diary_group: str = "auto",
128
+ ) -> str:
129
+ """Add a food entry to the Cronometer diary.
130
+
131
+ Use search_foods to find food_id and measure_id, then
132
+ get_food_details to confirm serving sizes and gram weights.
133
+
134
+ Args:
135
+ food_id: Numeric food ID from search_foods results.
136
+ measure_id: Measure/unit ID from get_food_details.
137
+ grams: Weight of the serving in grams.
138
+ date: Date to log as YYYY-MM-DD (defaults to today).
139
+ translation_id: Translation ID from search results (usually 0).
140
+ diary_group: Meal slot -- one of "auto", "breakfast", "lunch",
141
+ "dinner", "snacks" (case-insensitive, default "auto").
142
+ """
143
+ try:
144
+ group_map = {
145
+ "auto": 0,
146
+ "breakfast": 1,
147
+ "lunch": 2,
148
+ "dinner": 3,
149
+ "snacks": 4,
150
+ }
151
+ group_key = diary_group.strip().lower()
152
+ group_int = group_map.get(group_key)
153
+ if group_int is None:
154
+ return _err(
155
+ ValueError(
156
+ f"Invalid diary_group '{diary_group}'. "
157
+ "Must be one of: auto, breakfast, lunch, dinner, snacks."
158
+ )
159
+ )
160
+
161
+ client = _get_client()
162
+ day = _parse_date(date)
163
+ result = client.add_serving(
164
+ food_id=food_id,
165
+ measure_id=measure_id,
166
+ grams=grams,
167
+ translation_id=translation_id,
168
+ day=day,
169
+ diary_group=group_int,
170
+ )
171
+ return _ok(
172
+ {
173
+ "entry": result,
174
+ "note": "Use the serving ID to remove this entry with remove_food_entry.",
175
+ }
176
+ )
177
+ except Exception as e:
178
+ return _err(e)
179
+
180
+
181
+ @mcp.tool(
182
+ annotations={
183
+ "readOnlyHint": False,
184
+ "destructiveHint": True,
185
+ "idempotentHint": True,
186
+ "openWorldHint": True,
187
+ }
188
+ )
189
+ def remove_food_entry(
190
+ entry_ids: list[str],
191
+ date: str | None = None,
192
+ ) -> str:
193
+ """Remove one or more food entries from the Cronometer diary.
194
+
195
+ Use get_food_log to find entry IDs.
196
+
197
+ Args:
198
+ entry_ids: List of serving/entry IDs to remove.
199
+ date: Date the entries belong to as YYYY-MM-DD (defaults to today).
200
+ """
201
+ try:
202
+ client = _get_client()
203
+ day = _parse_date(date)
204
+ result = client.delete_entries(entry_ids, day)
205
+ return _ok(
206
+ {
207
+ "removed": result.get("removed", []),
208
+ "count": result.get("count", 0),
209
+ "date": date or str(date_module_today()),
210
+ }
211
+ )
212
+ except Exception as e:
213
+ return _err(e)
214
+
215
+
216
+ # ------------------------------------------------------------------
217
+ # Diary: management
218
+ # ------------------------------------------------------------------
219
+
220
+
221
+ @mcp.tool(
222
+ annotations={
223
+ "readOnlyHint": False,
224
+ "destructiveHint": False,
225
+ "idempotentHint": True,
226
+ "openWorldHint": True,
227
+ }
228
+ )
229
+ def mark_day_complete(date: str, complete: bool = True) -> str:
230
+ """Mark a diary day as complete or incomplete.
231
+
232
+ Args:
233
+ date: Date to mark as YYYY-MM-DD.
234
+ complete: True to mark complete, False for incomplete.
235
+ """
236
+ try:
237
+ client = _get_client()
238
+ day = _parse_date(date)
239
+ result = client.mark_day_complete(day, complete)
240
+ status = "complete" if complete else "incomplete"
241
+ return _ok(
242
+ {
243
+ "date": date,
244
+ "marked": status,
245
+ "result": result,
246
+ }
247
+ )
248
+ except Exception as e:
249
+ return _err(e)
250
+
251
+
252
+ @mcp.tool(
253
+ annotations={
254
+ "readOnlyHint": False,
255
+ "destructiveHint": False,
256
+ "idempotentHint": False,
257
+ "openWorldHint": True,
258
+ }
259
+ )
260
+ def copy_day(date: str | None = None) -> str:
261
+ """Copy all diary entries from the previous day to the given date.
262
+
263
+ Additive -- does not remove existing entries on the destination date.
264
+
265
+ Args:
266
+ date: Destination date as YYYY-MM-DD (defaults to today).
267
+ """
268
+ try:
269
+ client = _get_client()
270
+ day = _parse_date(date)
271
+ result = client.copy_day(to_day=day)
272
+ return _ok(
273
+ {
274
+ "destination_date": date or str(date_module_today()),
275
+ "result": result,
276
+ }
277
+ )
278
+ except Exception as e:
279
+ return _err(e)
280
+
281
+
282
+ # ------------------------------------------------------------------
283
+ # Nutrition
284
+ # ------------------------------------------------------------------
285
+
286
+
287
+ @mcp.tool(
288
+ annotations={
289
+ "readOnlyHint": True,
290
+ "destructiveHint": False,
291
+ "idempotentHint": True,
292
+ "openWorldHint": True,
293
+ }
294
+ )
295
+ def get_daily_nutrition(date: str | None = None) -> str:
296
+ """Get daily nutrition summary with macro and micronutrient totals.
297
+
298
+ Returns calorie, protein, carb, fat, fiber totals and micronutrients
299
+ for the given day.
300
+
301
+ Args:
302
+ date: Date as YYYY-MM-DD (defaults to today).
303
+ """
304
+ try:
305
+ client = _get_client()
306
+ day = _parse_date(date)
307
+ data = client.get_nutrients(day)
308
+ return _ok(
309
+ {
310
+ "date": date or str(date_module_today()),
311
+ "nutrients": data,
312
+ }
313
+ )
314
+ except Exception as e:
315
+ return _err(e)
316
+
317
+
318
+ @mcp.tool(
319
+ annotations={
320
+ "readOnlyHint": True,
321
+ "destructiveHint": False,
322
+ "idempotentHint": True,
323
+ "openWorldHint": True,
324
+ }
325
+ )
326
+ def get_nutrition_scores(date: str | None = None) -> str:
327
+ """Get nutrition scores with per-nutrient consumed amounts and category grades.
328
+
329
+ Returns category scores (All Targets, Vitamins, Minerals, Electrolytes,
330
+ Antioxidants, Immune Support, Metabolism, Bone Health) with the actual
331
+ consumed amount and confidence level for each tracked nutrient.
332
+
333
+ This is the richest nutrition endpoint -- use it when you need to know
334
+ both how much of each nutrient was consumed AND how close each is to
335
+ the target.
336
+
337
+ Args:
338
+ date: Date as YYYY-MM-DD (defaults to today).
339
+ """
340
+ try:
341
+ client = _get_client()
342
+ day = _parse_date(date)
343
+ data = client.get_nutrition_scores(day)
344
+ return _ok(
345
+ {
346
+ "date": date or str(date_module_today()),
347
+ "scores": data,
348
+ }
349
+ )
350
+ except Exception as e:
351
+ return _err(e)
352
+
353
+
354
+ # ------------------------------------------------------------------
355
+ # Food search and details
356
+ # ------------------------------------------------------------------
357
+
358
+
359
+ @mcp.tool(
360
+ annotations={
361
+ "readOnlyHint": True,
362
+ "destructiveHint": False,
363
+ "idempotentHint": True,
364
+ "openWorldHint": True,
365
+ }
366
+ )
367
+ def search_foods(query: str) -> str:
368
+ """Search Cronometer's food database by name.
369
+
370
+ Returns matching foods with their IDs and source information.
371
+ Use the food_id and measure_id from results with add_food_entry,
372
+ or pass food_id to get_food_details for full nutrition info.
373
+
374
+ Args:
375
+ query: Food name or keyword (e.g. "eggs", "chicken breast").
376
+ """
377
+ try:
378
+ client = _get_client()
379
+ foods = client.search_food(query)
380
+
381
+ # Slim down results to the most useful fields
382
+ results = []
383
+ for f in foods:
384
+ results.append(
385
+ {
386
+ "food_id": f.get("id"),
387
+ "name": f.get("name"),
388
+ "source": f.get("source"),
389
+ "measure_id": f.get("measureId"),
390
+ "translation_id": f.get("translationId"),
391
+ "measure_display": f.get("measureDisplayName"),
392
+ "score": f.get("score"),
393
+ }
394
+ )
395
+
396
+ return _ok(
397
+ {
398
+ "query": query,
399
+ "count": len(results),
400
+ "foods": results,
401
+ }
402
+ )
403
+ except Exception as e:
404
+ return _err(e)
405
+
406
+
407
+ @mcp.tool(
408
+ annotations={
409
+ "readOnlyHint": True,
410
+ "destructiveHint": False,
411
+ "idempotentHint": True,
412
+ "openWorldHint": True,
413
+ }
414
+ )
415
+ def get_food_details(food_id: int) -> str:
416
+ """Get detailed food information including nutrition and serving sizes.
417
+
418
+ Use this after search_foods to get the full nutrient profile and
419
+ available measure_ids needed for add_food_entry.
420
+
421
+ Args:
422
+ food_id: Food ID from search_foods results.
423
+ """
424
+ try:
425
+ client = _get_client()
426
+ data = client.get_food(food_id)
427
+
428
+ # Extract measures for easy reference
429
+ measures = []
430
+ for m in data.get("measures", []):
431
+ measures.append(
432
+ {
433
+ "measure_id": m.get("id"),
434
+ "name": m.get("name"),
435
+ "grams": m.get("value"),
436
+ }
437
+ )
438
+
439
+ return _ok(
440
+ {
441
+ "food_id": data.get("id"),
442
+ "name": data.get("name"),
443
+ "default_measure_id": data.get("defaultMeasureId"),
444
+ "measures": measures,
445
+ "nutrients": data.get("nutrients", []),
446
+ }
447
+ )
448
+ except Exception as e:
449
+ return _err(e)
450
+
451
+
452
+ # ------------------------------------------------------------------
453
+ # Custom food creation
454
+ # ------------------------------------------------------------------
455
+
456
+
457
+ @mcp.tool(
458
+ annotations={
459
+ "readOnlyHint": False,
460
+ "destructiveHint": False,
461
+ "idempotentHint": False,
462
+ "openWorldHint": True,
463
+ }
464
+ )
465
+ def add_custom_food(
466
+ name: str,
467
+ calories: float,
468
+ protein_g: float,
469
+ fat_g: float,
470
+ carbs_g: float,
471
+ fiber_g: float = 0,
472
+ sugar_g: float = 0,
473
+ sodium_mg: float = 0,
474
+ serving_name: str = "1 serving",
475
+ serving_grams: float = 100.0,
476
+ ) -> str:
477
+ """Create a custom food in Cronometer with specified nutrition.
478
+
479
+ Nutrient amounts should be for the full serving size specified.
480
+ After creation, use the returned food_id with add_food_entry to log it.
481
+
482
+ Args:
483
+ name: Food name.
484
+ calories: Calories per serving (kcal).
485
+ protein_g: Protein per serving (g).
486
+ fat_g: Fat per serving (g).
487
+ carbs_g: Carbs per serving (g).
488
+ fiber_g: Fiber per serving (g, default 0).
489
+ sugar_g: Sugar per serving (g, default 0).
490
+ sodium_mg: Sodium per serving (mg, default 0).
491
+ serving_name: Name for the serving size (default "1 serving").
492
+ serving_grams: Weight of one serving in grams (default 100).
493
+ """
494
+ try:
495
+ client = _get_client()
496
+ result = client.create_custom_food(
497
+ name,
498
+ calories=calories,
499
+ protein_g=protein_g,
500
+ fat_g=fat_g,
501
+ carbs_g=carbs_g,
502
+ fiber_g=fiber_g,
503
+ sugar_g=sugar_g,
504
+ sodium_mg=sodium_mg,
505
+ serving_name=serving_name,
506
+ serving_grams=serving_grams,
507
+ )
508
+
509
+ # Fetch back to get the server-assigned measure_id
510
+ food_data = client.get_food(result["food_id"])
511
+ result["measure_id"] = food_data.get("defaultMeasureId")
512
+
513
+ return _ok(
514
+ {
515
+ "food_id": result["food_id"],
516
+ "measure_id": result["measure_id"],
517
+ "name": name,
518
+ "note": "Use food_id and measure_id with add_food_entry to log this food.",
519
+ }
520
+ )
521
+ except Exception as e:
522
+ return _err(e)
523
+
524
+
525
+ # ------------------------------------------------------------------
526
+ # Macro targets
527
+ # ------------------------------------------------------------------
528
+
529
+
530
+ @mcp.tool(
531
+ annotations={
532
+ "readOnlyHint": True,
533
+ "destructiveHint": False,
534
+ "idempotentHint": True,
535
+ "openWorldHint": True,
536
+ }
537
+ )
538
+ def get_macro_targets() -> str:
539
+ """Get current macro targets including weekly schedule and templates.
540
+
541
+ Returns the weekly macro schedule (which template applies to each day)
542
+ and all saved macro target templates with their values.
543
+ """
544
+ try:
545
+ client = _get_client()
546
+ schedules = client.get_macro_schedules()
547
+ templates = client.get_macro_target_templates()
548
+ return _ok(
549
+ {
550
+ "schedules": schedules,
551
+ "templates": templates,
552
+ }
553
+ )
554
+ except Exception as e:
555
+ return _err(e)
556
+
557
+
558
+ # ------------------------------------------------------------------
559
+ # Fasting
560
+ # ------------------------------------------------------------------
561
+
562
+
563
+ @mcp.tool(
564
+ annotations={
565
+ "readOnlyHint": True,
566
+ "destructiveHint": False,
567
+ "idempotentHint": True,
568
+ "openWorldHint": True,
569
+ }
570
+ )
571
+ def get_fasting_history(
572
+ start_date: str | None = None,
573
+ end_date: str | None = None,
574
+ ) -> str:
575
+ """Get fasting history from Cronometer.
576
+
577
+ Returns fasts within the date range including status, timestamps,
578
+ and duration.
579
+
580
+ Args:
581
+ start_date: Start date as YYYY-MM-DD (defaults to 30 days ago).
582
+ end_date: End date as YYYY-MM-DD (defaults to today).
583
+ """
584
+ try:
585
+ client = _get_client()
586
+ start = _parse_date(start_date)
587
+ end = _parse_date(end_date)
588
+ data = client.get_fasting_with_date_range(start, end)
589
+ return _ok(
590
+ {
591
+ "start_date": start_date or str(date.today() - timedelta(days=30)),
592
+ "end_date": end_date or str(date.today()),
593
+ "fasting": data,
594
+ }
595
+ )
596
+ except Exception as e:
597
+ return _err(e)
598
+
599
+
600
+ @mcp.tool(
601
+ annotations={
602
+ "readOnlyHint": True,
603
+ "destructiveHint": False,
604
+ "idempotentHint": True,
605
+ "openWorldHint": True,
606
+ }
607
+ )
608
+ def get_fasting_stats() -> str:
609
+ """Get aggregate fasting statistics.
610
+
611
+ Returns total fasting hours, longest fast, average fast duration,
612
+ and completed fast count.
613
+ """
614
+ try:
615
+ client = _get_client()
616
+ data = client.get_fasting_stats()
617
+ return _ok({"stats": data})
618
+ except Exception as e:
619
+ return _err(e)
620
+
621
+
622
+ # ------------------------------------------------------------------
623
+ # Helpers
624
+ # ------------------------------------------------------------------
625
+
626
+
627
+ def date_module_today() -> date:
628
+ """Return today's date (extracted for easy mocking in tests)."""
629
+ return date.today()
630
+
631
+
632
+ # ------------------------------------------------------------------
633
+ # OAuth 2.1 Authorization Code + PKCE for remote transports
634
+ # ------------------------------------------------------------------
635
+
636
+
637
+ class OAuthAuthorizationMiddleware:
638
+ """ASGI middleware implementing OAuth 2.1 authorization code flow with PKCE.
639
+
640
+ This is a minimal single-user OAuth server that satisfies Claude.ai's
641
+ MCP connector requirements. It implements:
642
+
643
+ - GET /.well-known/oauth-authorization-server → server metadata (RFC 8414)
644
+ - GET /.well-known/oauth-protected-resource → resource metadata
645
+ - GET /authorize → authorization page (user clicks to approve)
646
+ - POST /token → authorization code → access token exchange (with PKCE)
647
+ - Bearer token validation on all other HTTP requests
648
+
649
+ Auth codes are single-use and short-lived (in-memory, lost on restart).
650
+ Since this is a single-user server, the "authorization" is just confirming
651
+ you're the server owner.
652
+
653
+ Env vars:
654
+ - MCP_OAUTH_CLIENT_ID: Expected client_id from Claude.ai
655
+ - MCP_OAUTH_CLIENT_SECRET: Expected client_secret from Claude.ai
656
+ - MCP_AUTH_TOKEN: The access token issued and validated
657
+ - MCP_BASE_URL: Public base URL (for metadata endpoints)
658
+ """
659
+
660
+ def __init__(
661
+ self,
662
+ app,
663
+ *,
664
+ client_id: str,
665
+ client_secret: str,
666
+ access_token: str,
667
+ base_url: str = "",
668
+ ):
669
+ self.app = app
670
+ self.client_id = client_id
671
+ self.client_secret = client_secret
672
+ self.access_token = access_token
673
+ self.base_url = base_url.rstrip("/")
674
+ # In-memory store for pending auth codes: code -> {code_challenge, redirect_uri, state}
675
+ self._pending_codes: dict[str, dict] = {}
676
+
677
+ async def __call__(self, scope, receive, send):
678
+ if scope["type"] != "http":
679
+ await self.app(scope, receive, send)
680
+ return
681
+
682
+ path = scope.get("path", "")
683
+ method = scope.get("method", "GET")
684
+
685
+ # OAuth metadata discovery (RFC 8414)
686
+ if path == "/.well-known/oauth-authorization-server" and method == "GET":
687
+ from starlette.responses import JSONResponse
688
+
689
+ response = JSONResponse(
690
+ {
691
+ "issuer": self.base_url,
692
+ "authorization_endpoint": f"{self.base_url}/authorize",
693
+ "token_endpoint": f"{self.base_url}/token",
694
+ "registration_endpoint": f"{self.base_url}/register",
695
+ "token_endpoint_auth_methods_supported": [
696
+ "client_secret_post",
697
+ ],
698
+ "grant_types_supported": [
699
+ "authorization_code",
700
+ ],
701
+ "response_types_supported": ["code"],
702
+ "code_challenge_methods_supported": ["S256"],
703
+ "scopes_supported": ["mcp"],
704
+ }
705
+ )
706
+ await response(scope, receive, send)
707
+ return
708
+
709
+ # OAuth Protected Resource metadata
710
+ if path == "/.well-known/oauth-protected-resource" and method == "GET":
711
+ from starlette.responses import JSONResponse
712
+
713
+ response = JSONResponse(
714
+ {
715
+ "resource": self.base_url,
716
+ "authorization_servers": [self.base_url],
717
+ "scopes_supported": ["mcp"],
718
+ }
719
+ )
720
+ await response(scope, receive, send)
721
+ return
722
+
723
+ # Dynamic client registration (stub -- just echoes back the client_id)
724
+ if path == "/register" and method == "POST":
725
+ await self._handle_register(scope, receive, send)
726
+ return
727
+
728
+ # Authorization endpoint
729
+ if path == "/authorize" and method == "GET":
730
+ await self._handle_authorize(scope, receive, send)
731
+ return
732
+
733
+ # Authorize form submission
734
+ if path == "/authorize" and method == "POST":
735
+ await self._handle_authorize_submit(scope, receive, send)
736
+ return
737
+
738
+ # Token endpoint
739
+ if path == "/token" and method == "POST":
740
+ await self._handle_token(scope, receive, send)
741
+ return
742
+
743
+ # Favicon (don't 401 on this during authorize page load)
744
+ if path == "/favicon.ico":
745
+ from starlette.responses import Response
746
+
747
+ await Response(status_code=404)(scope, receive, send)
748
+ return
749
+
750
+ # All other requests: validate bearer token
751
+ headers = dict(scope.get("headers", []))
752
+ auth_value = headers.get(b"authorization", b"").decode()
753
+ if auth_value != f"Bearer {self.access_token}":
754
+ from starlette.responses import Response
755
+
756
+ # Return 401 with WWW-Authenticate header per MCP spec
757
+ response = Response(
758
+ content='{"error": "unauthorized"}',
759
+ status_code=401,
760
+ headers={
761
+ "WWW-Authenticate": "Bearer",
762
+ "Content-Type": "application/json",
763
+ },
764
+ )
765
+ await response(scope, receive, send)
766
+ return
767
+
768
+ await self.app(scope, receive, send)
769
+
770
+ async def _handle_register(self, scope, receive, send):
771
+ """Handle POST /register (OAuth 2.0 Dynamic Client Registration).
772
+
773
+ Minimal implementation that accepts any registration and returns
774
+ the provided client_name with pre-configured credentials.
775
+ """
776
+ from starlette.responses import JSONResponse
777
+
778
+ body = await self._read_body(receive)
779
+ try:
780
+ import json as _json
781
+
782
+ data = _json.loads(body)
783
+ except Exception:
784
+ data = {}
785
+
786
+ response = JSONResponse(
787
+ {
788
+ "client_id": self.client_id,
789
+ "client_secret": self.client_secret,
790
+ "client_name": data.get("client_name", "mcp-client"),
791
+ "redirect_uris": data.get("redirect_uris", []),
792
+ "grant_types": ["authorization_code"],
793
+ "response_types": ["code"],
794
+ "token_endpoint_auth_method": "client_secret_post",
795
+ },
796
+ status_code=201,
797
+ )
798
+ await response(scope, receive, send)
799
+
800
+ async def _handle_authorize(self, scope, receive, send):
801
+ """Handle GET /authorize -- show a simple approval page."""
802
+ from starlette.responses import HTMLResponse
803
+ from urllib.parse import parse_qs
804
+
805
+ query = scope.get("query_string", b"").decode()
806
+ params = parse_qs(query)
807
+
808
+ client_id = params.get("client_id", [None])[0]
809
+ redirect_uri = params.get("redirect_uri", [None])[0]
810
+ code_challenge = params.get("code_challenge", [None])[0]
811
+ code_challenge_method = params.get("code_challenge_method", [None])[0]
812
+ state = params.get("state", [None])[0]
813
+ response_type = params.get("response_type", [None])[0]
814
+
815
+ if response_type != "code" or not redirect_uri or not code_challenge:
816
+ from starlette.responses import JSONResponse
817
+
818
+ response = JSONResponse({"error": "invalid_request"}, status_code=400)
819
+ await response(scope, receive, send)
820
+ return
821
+
822
+ # Render a simple approval page
823
+ html = f"""<!DOCTYPE html>
824
+ <html>
825
+ <head>
826
+ <title>Authorize MCP Access</title>
827
+ <meta name="viewport" content="width=device-width, initial-scale=1">
828
+ <style>
829
+ body {{ font-family: system-ui, sans-serif; max-width: 480px; margin: 60px auto; padding: 20px; }}
830
+ h1 {{ font-size: 1.4em; }}
831
+ .info {{ background: #f0f4f8; padding: 16px; border-radius: 8px; margin: 20px 0; }}
832
+ button {{ background: #2563eb; color: white; border: none; padding: 12px 32px;
833
+ border-radius: 6px; font-size: 16px; cursor: pointer; }}
834
+ button:hover {{ background: #1d4ed8; }}
835
+ </style>
836
+ </head>
837
+ <body>
838
+ <h1>Authorize Cronometer MCP</h1>
839
+ <div class="info">
840
+ <p><strong>Client:</strong> {client_id or "unknown"}</p>
841
+ <p>This will grant access to your Cronometer nutrition data
842
+ through the MCP protocol.</p>
843
+ </div>
844
+ <form method="POST" action="/authorize">
845
+ <input type="hidden" name="client_id" value="{client_id or ""}">
846
+ <input type="hidden" name="redirect_uri" value="{redirect_uri or ""}">
847
+ <input type="hidden" name="code_challenge" value="{code_challenge or ""}">
848
+ <input type="hidden" name="code_challenge_method" value="{code_challenge_method or ""}">
849
+ <input type="hidden" name="state" value="{state or ""}">
850
+ <button type="submit">Authorize</button>
851
+ </form>
852
+ </body>
853
+ </html>"""
854
+ response = HTMLResponse(html)
855
+ await response(scope, receive, send)
856
+
857
+ async def _handle_authorize_submit(self, scope, receive, send):
858
+ """Handle POST /authorize -- user approved, generate code and redirect."""
859
+ from starlette.responses import RedirectResponse
860
+ from urllib.parse import parse_qs, urlencode
861
+ import secrets
862
+
863
+ body = await self._read_body(receive)
864
+ params = parse_qs(body.decode("utf-8"))
865
+
866
+ redirect_uri = params.get("redirect_uri", [None])[0]
867
+ code_challenge = params.get("code_challenge", [None])[0]
868
+ code_challenge_method = params.get("code_challenge_method", [None])[0]
869
+ state = params.get("state", [None])[0]
870
+
871
+ if not redirect_uri or not code_challenge:
872
+ from starlette.responses import JSONResponse
873
+
874
+ response = JSONResponse({"error": "invalid_request"}, status_code=400)
875
+ await response(scope, receive, send)
876
+ return
877
+
878
+ # Generate a one-time auth code
879
+ code = secrets.token_urlsafe(32)
880
+ self._pending_codes[code] = {
881
+ "code_challenge": code_challenge,
882
+ "code_challenge_method": code_challenge_method or "S256",
883
+ "redirect_uri": redirect_uri,
884
+ }
885
+
886
+ # Redirect back to Claude.ai with the code
887
+ callback_params = {"code": code}
888
+ if state:
889
+ callback_params["state"] = state
890
+ separator = "&" if "?" in redirect_uri else "?"
891
+ redirect_url = f"{redirect_uri}{separator}{urlencode(callback_params)}"
892
+
893
+ response = RedirectResponse(url=redirect_url, status_code=302)
894
+ await response(scope, receive, send)
895
+
896
+ async def _handle_token(self, scope, receive, send):
897
+ """Handle POST /token (authorization code exchange with PKCE)."""
898
+ from starlette.responses import JSONResponse
899
+ from urllib.parse import parse_qs
900
+ import hashlib
901
+ import base64
902
+
903
+ body = await self._read_body(receive)
904
+ try:
905
+ params = parse_qs(body.decode("utf-8"))
906
+ except Exception:
907
+ response = JSONResponse({"error": "invalid_request"}, status_code=400)
908
+ await response(scope, receive, send)
909
+ return
910
+
911
+ grant_type = params.get("grant_type", [None])[0]
912
+ code = params.get("code", [None])[0]
913
+ code_verifier = params.get("code_verifier", [None])[0]
914
+
915
+ if grant_type != "authorization_code":
916
+ response = JSONResponse(
917
+ {"error": "unsupported_grant_type"}, status_code=400
918
+ )
919
+ await response(scope, receive, send)
920
+ return
921
+
922
+ if not code or not code_verifier:
923
+ response = JSONResponse({"error": "invalid_request"}, status_code=400)
924
+ await response(scope, receive, send)
925
+ return
926
+
927
+ # Look up and consume the auth code (single-use)
928
+ pending = self._pending_codes.pop(code, None)
929
+ if pending is None:
930
+ response = JSONResponse({"error": "invalid_grant"}, status_code=400)
931
+ await response(scope, receive, send)
932
+ return
933
+
934
+ # Validate PKCE: S256(code_verifier) must match code_challenge
935
+ code_challenge = pending["code_challenge"]
936
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
937
+ computed_challenge = (
938
+ base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
939
+ )
940
+
941
+ if computed_challenge != code_challenge:
942
+ response = JSONResponse(
943
+ {
944
+ "error": "invalid_grant",
945
+ "error_description": "PKCE verification failed",
946
+ },
947
+ status_code=400,
948
+ )
949
+ await response(scope, receive, send)
950
+ return
951
+
952
+ # Issue access token
953
+ response = JSONResponse(
954
+ {
955
+ "access_token": self.access_token,
956
+ "token_type": "bearer",
957
+ "scope": "mcp",
958
+ }
959
+ )
960
+ await response(scope, receive, send)
961
+
962
+ @staticmethod
963
+ async def _read_body(receive) -> bytes:
964
+ """Read the full request body from ASGI receive."""
965
+ body = b""
966
+ while True:
967
+ message = await receive()
968
+ body += message.get("body", b"")
969
+ if not message.get("more_body", False):
970
+ break
971
+ return body
972
+
973
+
974
+ # ------------------------------------------------------------------
975
+ # Entry point
976
+ # ------------------------------------------------------------------
977
+
978
+
979
+ def main():
980
+ transport = os.getenv("MCP_TRANSPORT", "stdio")
981
+
982
+ if transport == "stdio":
983
+ mcp.run(transport="stdio")
984
+
985
+ elif transport in ("sse", "streamable-http"):
986
+ import uvicorn
987
+
988
+ host = os.getenv("FASTMCP_HOST", "0.0.0.0")
989
+ port = int(os.getenv("PORT", os.getenv("FASTMCP_PORT", "8000")))
990
+
991
+ if transport == "sse":
992
+ app = mcp.sse_app()
993
+ else:
994
+ app = mcp.streamable_http_app()
995
+
996
+ # OAuth config for remote transports
997
+ client_id = os.getenv("MCP_OAUTH_CLIENT_ID", "")
998
+ client_secret = os.getenv("MCP_OAUTH_CLIENT_SECRET", "")
999
+ access_token = os.getenv("MCP_AUTH_TOKEN")
1000
+ base_url = os.getenv("MCP_BASE_URL", f"http://localhost:{port}")
1001
+
1002
+ if access_token:
1003
+ logger.info("OAuth authorization code flow enabled")
1004
+ app = OAuthAuthorizationMiddleware(
1005
+ app,
1006
+ client_id=client_id,
1007
+ client_secret=client_secret,
1008
+ access_token=access_token,
1009
+ base_url=base_url,
1010
+ )
1011
+ else:
1012
+ logger.warning("No auth configured -- server is unauthenticated")
1013
+
1014
+ logger.info("Starting %s transport on %s:%d", transport, host, port)
1015
+ config = uvicorn.Config(app, host=host, port=port, log_level="info")
1016
+ server = uvicorn.Server(config)
1017
+ server.run()
1018
+
1019
+ else:
1020
+ raise ValueError(
1021
+ f"Unknown MCP_TRANSPORT: {transport!r}. "
1022
+ "Must be 'stdio', 'sse', or 'streamable-http'."
1023
+ )
1024
+
1025
+
1026
+ if __name__ == "__main__":
1027
+ main()