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.
- cronometer_api_mcp/__init__.py +5 -0
- cronometer_api_mcp/client.py +699 -0
- cronometer_api_mcp/server.py +1027 -0
- cronometer_api_mcp-0.1.0.dist-info/METADATA +225 -0
- cronometer_api_mcp-0.1.0.dist-info/RECORD +8 -0
- cronometer_api_mcp-0.1.0.dist-info/WHEEL +4 -0
- cronometer_api_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- cronometer_api_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|