mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 11:13:48 +08:00
490 lines
18 KiB
Python
490 lines
18 KiB
Python
"""MCP server implementation for Mi Fitness."""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import TextContent, Tool
|
|
|
|
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
|
from mi_fitness_mcp.auth import load_mi_fitness_token
|
|
from mi_fitness_mcp.config import load_config
|
|
from mi_fitness_mcp.models import ConnectionStatus, QueryResponse
|
|
from mi_fitness_mcp.services.query_service import QueryService
|
|
from mi_fitness_mcp.services.sync_service import SyncService
|
|
from mi_fitness_mcp.storage import Database
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = Server("mi-fitness-mcp")
|
|
|
|
config = None
|
|
db = None
|
|
adapter = None
|
|
sync_service = None
|
|
query_service = None
|
|
|
|
|
|
@app.list_tools()
|
|
async def list_tools() -> list[Tool]:
|
|
return [
|
|
Tool(
|
|
name="get_connection_status",
|
|
description="Check connection status",
|
|
inputSchema={"type": "object", "properties": {}},
|
|
),
|
|
Tool(
|
|
name="sync_data",
|
|
description="Synchronize Mi Fitness data",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"data_types": {"type": "array", "items": {"type": "string"}},
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"force_full_sync": {"type": "boolean"},
|
|
},
|
|
},
|
|
),
|
|
Tool(
|
|
name="get_profile",
|
|
description="Get user profile information",
|
|
inputSchema={"type": "object", "properties": {}},
|
|
),
|
|
Tool(
|
|
name="get_daily_summary",
|
|
description="Get daily activity summary",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"date": {"type": "string"},
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_metric_series",
|
|
description="Query metric series",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"metric": {
|
|
"type": "string",
|
|
"enum": ["steps", "distance_m", "active_kcal", "weight_kg"],
|
|
},
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"granularity": {"type": "string", "enum": ["day", "week", "month"]},
|
|
"aggregation": {
|
|
"type": "string",
|
|
"enum": ["sum", "avg", "min", "max", "latest"],
|
|
},
|
|
},
|
|
"required": ["metric", "start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_heart_rate",
|
|
description="Query heart rate samples",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"sample_type": {
|
|
"type": "string",
|
|
"enum": ["resting", "active", "passive", "workout"],
|
|
},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_body_measurements",
|
|
description="Query body measurements",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"metrics": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "string",
|
|
"enum": [
|
|
"weight_kg",
|
|
"bmi",
|
|
"body_fat_pct",
|
|
"muscle_mass_kg",
|
|
"water_pct",
|
|
],
|
|
},
|
|
},
|
|
"latest_only": {"type": "boolean"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_sleep",
|
|
description="Query sleep sessions",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"include_naps": {"type": "boolean"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_workouts",
|
|
description="Query workouts",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"activity_types": {"type": "array", "items": {"type": "string"}},
|
|
"min_duration": {"type": "integer"},
|
|
"min_distance_km": {"type": "number"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_spo2",
|
|
description="Query blood oxygen saturation samples",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_stress",
|
|
description="Query stress samples",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"level": {"type": "string", "enum": ["low", "medium", "high"]},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_abnormal_heart_beat",
|
|
description="Query abnormal heart beat events",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="get_data_coverage",
|
|
description="Get data coverage",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"data_types": {"type": "array", "items": {"type": "string"}}},
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@app.call_tool()
|
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
try:
|
|
if name == "get_connection_status":
|
|
result = await _handle_get_connection_status()
|
|
elif name == "sync_data":
|
|
result = await _handle_sync_data(arguments)
|
|
elif name == "get_profile":
|
|
result = await _handle_get_profile()
|
|
elif name == "get_daily_summary":
|
|
result = await _handle_get_daily_summary(arguments)
|
|
elif name == "query_metric_series":
|
|
result = await _handle_query_metric_series(arguments)
|
|
elif name == "query_heart_rate":
|
|
result = await _handle_query_heart_rate(arguments)
|
|
elif name == "query_body_measurements":
|
|
result = await _handle_query_body_measurements(arguments)
|
|
elif name == "query_sleep":
|
|
result = await _handle_query_sleep(arguments)
|
|
elif name == "query_workouts":
|
|
result = await _handle_query_workouts(arguments)
|
|
elif name == "query_spo2":
|
|
result = await _handle_query_spo2(arguments)
|
|
elif name == "query_stress":
|
|
result = await _handle_query_stress(arguments)
|
|
elif name == "query_abnormal_heart_beat":
|
|
result = await _handle_query_abnormal_heart_beat(arguments)
|
|
elif name == "get_data_coverage":
|
|
result = await _handle_get_data_coverage(arguments)
|
|
else:
|
|
result = {"status": "error", "error": f"Unknown tool: {name}"}
|
|
return [TextContent(type="text", text=json.dumps(result, default=str))]
|
|
except Exception as e:
|
|
logger.exception("Mi Fitness tool error")
|
|
return [TextContent(type="text", text=json.dumps({"status": "error", "error": str(e)}))]
|
|
|
|
|
|
async def _handle_get_connection_status() -> dict:
|
|
global adapter, config
|
|
if not config or config.mode == "not_configured":
|
|
return ConnectionStatus(
|
|
mode="not_configured", connected=False, message="Server not configured."
|
|
).model_dump()
|
|
|
|
connected = adapter is not None and adapter.is_connected()
|
|
last_sync = None
|
|
available_types = []
|
|
if db:
|
|
for data_type in [
|
|
"daily_activity",
|
|
"heart_rate",
|
|
"body_measurements",
|
|
"sleep",
|
|
"workouts",
|
|
"spo2",
|
|
"stress",
|
|
"abnormal_heart_beat",
|
|
]:
|
|
state = db.get_sync_state(data_type)
|
|
if state and state.get("last_sync_at"):
|
|
available_types.append(data_type)
|
|
sync_time = datetime.fromisoformat(state["last_sync_at"])
|
|
if last_sync is None or sync_time > last_sync:
|
|
last_sync = sync_time
|
|
return ConnectionStatus(
|
|
mode=config.mode,
|
|
connected=connected,
|
|
last_sync_at=last_sync,
|
|
available_data_types=available_types,
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_sync_data(arguments: dict) -> dict:
|
|
if not sync_service:
|
|
return {"status": "error", "error": "Sync service not initialized"}
|
|
data_types = arguments.get("data_types") or sync_service.adapter.get_available_data_types()
|
|
sync_id = str(uuid.uuid4())
|
|
started_at = datetime.utcnow()
|
|
total_added = 0
|
|
total_updated = 0
|
|
total_skipped = 0
|
|
types_synced = []
|
|
for data_type in data_types:
|
|
try:
|
|
result = await sync_service.sync_data_type(
|
|
data_type=data_type,
|
|
start_date=arguments.get("start_date"),
|
|
end_date=arguments.get("end_date"),
|
|
force_full=arguments.get("force_full_sync", False),
|
|
)
|
|
total_added += result.get("added", 0)
|
|
total_updated += result.get("updated", 0)
|
|
total_skipped += result.get("skipped", 0)
|
|
types_synced.append(data_type)
|
|
except Exception as e:
|
|
logger.error(f"Failed to sync {data_type}: {e}")
|
|
finished_at = datetime.utcnow()
|
|
return {
|
|
"status": "ok",
|
|
"sync_id": sync_id,
|
|
"started_at": started_at.isoformat(),
|
|
"finished_at": finished_at.isoformat(),
|
|
"records_added": total_added,
|
|
"records_updated": total_updated,
|
|
"records_skipped": total_skipped,
|
|
"data_types_synced": types_synced,
|
|
}
|
|
|
|
|
|
async def _handle_get_profile() -> dict:
|
|
if not adapter or not adapter.is_connected():
|
|
return {"status": "error", "error": "Not connected to data source"}
|
|
return QueryResponse(
|
|
status="ok",
|
|
source=config.mode if config else "unknown",
|
|
data={
|
|
"profile": {
|
|
"user_id": adapter.get_user_id() or "unknown",
|
|
"timezone": config.timezone if config else "UTC",
|
|
"devices": [],
|
|
}
|
|
},
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_get_daily_summary(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
start_date = arguments.get("date") or arguments.get("start_date")
|
|
end_date = arguments.get("date") or arguments.get("end_date")
|
|
if not start_date or not end_date:
|
|
return {"status": "error", "error": "date or start_date/end_date required"}
|
|
summaries = query_service.get_daily_summaries(start_date, end_date)
|
|
return QueryResponse(status="ok", source="cache", data={"summaries": summaries}).model_dump()
|
|
|
|
|
|
async def _handle_query_metric_series(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
series = query_service.get_metric_series(
|
|
metric=arguments["metric"],
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
granularity=arguments.get("granularity", "day"),
|
|
aggregation=arguments.get("aggregation", "sum"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"metric": arguments["metric"], "series": series}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_heart_rate(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
samples = query_service.get_heart_rate_samples(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
sample_type=arguments.get("sample_type"),
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"samples": samples, "count": len(samples)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_body_measurements(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
measurements = query_service.get_body_measurements(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
metrics=arguments.get("metrics"),
|
|
)
|
|
if arguments.get("latest_only") and measurements:
|
|
measurements = [measurements[-1]]
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"measurements": measurements, "count": len(measurements)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_sleep(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
sessions = query_service.get_sleep_sessions(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
include_naps=arguments.get("include_naps", True),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"sessions": sessions, "count": len(sessions)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_workouts(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
workouts = query_service.get_workouts(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
activity_types=arguments.get("activity_types"),
|
|
min_duration=arguments.get("min_duration"),
|
|
min_distance_km=arguments.get("min_distance_km"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"workouts": workouts, "count": len(workouts)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_spo2(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
samples = query_service.get_spo2_samples(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"samples": samples, "count": len(samples)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_stress(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
samples = query_service.get_stress_samples(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
level=arguments.get("level"),
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"samples": samples, "count": len(samples)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_abnormal_heart_beat(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
events = query_service.get_abnormal_heart_beat_events(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"events": events, "count": len(events)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_get_data_coverage(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
coverage = query_service.get_data_coverage(arguments.get("data_types"))
|
|
return QueryResponse(status="ok", source="cache", data={"coverage": coverage}).model_dump()
|
|
|
|
|
|
async def main():
|
|
global config, db, adapter, sync_service, query_service
|
|
config = load_config()
|
|
db = Database(config.database_path)
|
|
if config.mode == "mi_fitness_cloud":
|
|
user_id, pass_token = load_mi_fitness_token()
|
|
if user_id and pass_token:
|
|
adapter = MiFitnessCloudAdapter(
|
|
user_id=user_id, pass_token=pass_token, region=config.region
|
|
)
|
|
if await adapter.connect():
|
|
logger.info("Connected to Mi Fitness cloud API")
|
|
else:
|
|
logger.warning("Failed to connect to Mi Fitness cloud API")
|
|
if adapter:
|
|
sync_service = SyncService(adapter, db)
|
|
query_service = QueryService(db, adapter.get_user_id() or "unknown")
|
|
else:
|
|
query_service = QueryService(db, "unknown")
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await app.run(read_stream, write_stream, app.create_initialization_options())
|