feat: prepare mi-fitness-mcp for first public release

Package the standalone Mi Fitness MCP with clearer public docs, release metadata, CI, and safer local repository hygiene so it is ready to publish without secrets or work identity leakage.
This commit is contained in:
Aleksej Kubulashvili committed 2026-03-12 14:12:49 +03:00
1 parent 6771fc841c
commit df914ea56a
23 files changed
+700 -372

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
MI_FITNESS_USER_ID=replace_me
MI_FITNESS_PASS_TOKEN=replace_me
MI_FITNESS_REGION=ru
+30
View File
@@ -0,0 +1,30 @@
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Ruff
run: |
python -m ruff check src tests
- name: Pytest
run: |
pytest -q
- name: Build
run: |
python -m build
+13
View File
@@ -20,6 +20,7 @@ build/
htmlcov/
.tox/
.mypy_cache/
.ruff_cache/
.dmypy.json
*.iml
.idea/
@@ -27,3 +28,15 @@ htmlcov/
*.swp
*.swo
*~
# Local runtime data
logs/
data/
exports/
tmp/
config.json
*.local.json
*.sqlite
*.sqlite-*
*.db-*
*.zip
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 0.1.0
- initial standalone `mi-fitness-mcp` repository
- Xiaomi auth via `userId + passToken`
- Mi Fitness cloud sync for steps, heart rate, calories, body measurements
- MCP tools for sync, summaries, heart rate, body measurements, coverage
+25
View File
@@ -0,0 +1,25 @@
# Contributing
Thanks for contributing.
## Local setup
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
```
## Before opening a change
- keep local config, SQLite data, and captured cloud payloads out of git
- never commit real `userId` or `passToken` values
- keep README and examples aligned with actual supported data types
## Checks
```bash
ruff check src tests
pytest
python -m build
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aleksej Kubulashvili
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+99 -10
View File
@@ -1,32 +1,76 @@
# Mi Fitness MCP
Отдельный MCP-сервер для `Mi Fitness`.
MCP server for Mi Fitness data.
Подтверждённые данные:
This project provides a local SQLite-backed MCP server for Mi Fitness cloud data.
- шаги
- калории
- пульс
- вес и состав тела
## Current data coverage
## Установка
Confirmed with the current cloud flow:
- daily activity
- steps
- distance
- active calories
- heart rate
- body measurements
- weight
- BMI
- fat, water, bone, and muscle metrics
- visceral fat
- basal metabolism
Not yet confirmed with the current Xiaomi cloud endpoint:
- sleep
- workouts
## Install
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e "."
pip install -e '.[dev]'
```
## Команды
## Setup
You need:
- `userId`
- `passToken`
Typical flow:
1. Open `https://account.xiaomi.com`
2. Sign in to your Xiaomi account
3. Open browser DevTools
4. Inspect cookies for `account.xiaomi.com`
5. Copy `userId` and `passToken`
Configure the server:
```bash
mi-fitness-mcp setup --mode mi_fitness_cloud --user-id "<userId>" --pass-token "<passToken>" --region ru
mi-fitness-mcp doctor
```
For local endpoint exploration there is also a probe script:
```bash
python probe_mifitness.py --user-id "<userId>" --pass-token "<passToken>"
```
## Use
```bash
mi-fitness-mcp sync --start-date 2025-04-01 --end-date 2025-05-31
mi-fitness-mcp serve
```
## Настройка Claude Desktop
## MCP client config
Example `Claude Desktop` config:
```json
{
@@ -38,3 +82,48 @@ mi-fitness-mcp serve
}
}
```
## Example prompts
- `Show my daily activity for the last 14 days`
- `How has my resting heart rate changed this month?`
- `Summarize my latest body measurements`
- `Sync my latest Mi Fitness data`
## Commands
```bash
mi-fitness-mcp --help
mi-fitness-mcp setup --help
mi-fitness-mcp doctor
mi-fitness-mcp sync --help
mi-fitness-mcp serve
```
## Development
```bash
pytest
python -m build
```
## Troubleshooting
- `Connection: failed`
- verify `userId` and `passToken`
- verify region, usually `ru`
- `Credentials not found`
- run `setup` again
- `sync` returns no data
- try another date range
- verify that the data actually exists in Mi Fitness cloud
## Security
- `passToken` is stored via the system keyring
- do not commit `.env`, local config files, or real credentials
- rotate tokens if they were pasted into chats or shell history
## Disclaimer
This is an unofficial project and is not affiliated with Xiaomi.
+13
View File
@@ -0,0 +1,13 @@
# Security
This project stores credentials in the operating system keychain via `keyring`.
## Safe usage
- do not commit local config files, databases, or captured payloads
- avoid pasting `passToken` values into shell history when possible
- treat leaked `passToken` values as compromised and rotate them
## Reporting issues
If you find a vulnerability or a secret leak, do not post the secret in a public GitHub issue. Share only the minimum details needed to reproduce the issue.
+7
View File
@@ -9,6 +9,10 @@ description = "MCP server for Mi Fitness data access"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
authors = [
{name = "Aleksej Kubulashvili", email = "kubulashvili@users.noreply.github.com"},
]
keywords = ["mcp", "model-context-protocol", "mi-fitness", "xiaomi", "fitness", "health"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: End Users/Desktop",
@@ -30,6 +34,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"build>=1.2.2",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"respx>=0.21.0",
@@ -45,6 +50,8 @@ packages = ["src/mi_fitness_mcp"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
+7 -8
View File
@@ -1,9 +1,8 @@
"""Base adapter interface for data sources."""
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Any, Coroutine, Union
from collections.abc import AsyncIterator, Coroutine, Iterator
from typing import Any, Union
from mi_fitness_mcp.models import (
BodyMeasurement,
@@ -41,7 +40,7 @@ class DataAdapter(ABC):
self,
start_date: str | None = None,
end_date: str | None = None,
) -> Union[Iterator[DailyActivity], AsyncIterator[DailyActivity]]:
) -> Iterator[DailyActivity] | AsyncIterator[DailyActivity]:
"""Iterate over daily activity records."""
pass
@@ -50,7 +49,7 @@ class DataAdapter(ABC):
self,
start_date: str | None = None,
end_date: str | None = None,
) -> Union[Iterator[SleepSession], AsyncIterator[SleepSession]]:
) -> Iterator[SleepSession] | AsyncIterator[SleepSession]:
"""Iterate over sleep session records."""
pass
@@ -59,7 +58,7 @@ class DataAdapter(ABC):
self,
start_date: str | None = None,
end_date: str | None = None,
) -> Union[Iterator[Workout], AsyncIterator[Workout]]:
) -> Iterator[Workout] | AsyncIterator[Workout]:
"""Iterate over workout records."""
pass
@@ -68,7 +67,7 @@ class DataAdapter(ABC):
self,
start_date: str | None = None,
end_date: str | None = None,
) -> Union[Iterator[BodyMeasurement], AsyncIterator[BodyMeasurement]]:
) -> Iterator[BodyMeasurement] | AsyncIterator[BodyMeasurement]:
"""Iterate over body measurement records."""
pass
@@ -77,7 +76,7 @@ class DataAdapter(ABC):
self,
start_date: str | None = None,
end_date: str | None = None,
) -> Union[Iterator[HeartRateSample], AsyncIterator[HeartRateSample]]:
) -> Iterator[HeartRateSample] | AsyncIterator[HeartRateSample]:
"""Iterate over heart rate records."""
pass
+70 -37
View File
@@ -1,8 +1,8 @@
import asyncio
import base64
import hashlib
import json
import os
import random
import struct
from collections import defaultdict
from collections.abc import AsyncIterator
@@ -13,8 +13,13 @@ from urllib.parse import urlencode
import httpx
from mi_fitness_mcp.adapters.base import DataAdapter
from mi_fitness_mcp.models import BodyMeasurement, DailyActivity, HeartRateSample, SleepSession, Workout
from mi_fitness_mcp.models import (
BodyMeasurement,
DailyActivity,
HeartRateSample,
SleepSession,
Workout,
)
LOGIN_PREFIX = b"&&&START&&&"
KNOWN_REGIONS = ["ru", "cn", "de", "i2", "sg", "us"]
@@ -24,7 +29,7 @@ def _read_login_payload(text: str) -> dict:
payload = text.encode()
if not payload.startswith(LOGIN_PREFIX):
raise RuntimeError("unexpected Xiaomi login response")
return json.loads(payload[len(LOGIN_PREFIX):].decode())
return json.loads(payload[len(LOGIN_PREFIX) :].decode())
def _rc4_crypt(key: bytes, payload: bytes) -> bytes:
@@ -72,7 +77,9 @@ def _gen_signature(method: str, path: str, values: dict[str, str], signed_nonce:
class MiFitnessCloudAdapter(DataAdapter):
def __init__(self, user_id: str | None = None, pass_token: str | None = None, region: str = "ru"):
def __init__(
self, user_id: str | None = None, pass_token: str | None = None, region: str = "ru"
):
self.user_id = user_id
self.pass_token = pass_token
self.region = region
@@ -107,6 +114,11 @@ class MiFitnessCloudAdapter(DataAdapter):
def get_available_data_types(self) -> list[str]:
return self._available_types.copy()
async def _close_client(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def _login_with_token(self, user_id: str, pass_token: str) -> None:
if not self._client:
raise RuntimeError("client not initialized")
@@ -130,38 +142,57 @@ class MiFitnessCloudAdapter(DataAdapter):
if not self._client:
raise RuntimeError("client not initialized")
form = {"data": json.dumps(payload, separators=(",", ":"))}
nonce = _gen_nonce()
signed_nonce = _gen_signed_nonce(self._ssecurity, nonce)
form["rc4_hash__"] = _gen_signature("POST", api_path, form, signed_nonce)
last_error: Exception | None = None
for attempt in range(3):
try:
form = {"data": json.dumps(payload, separators=(",", ":"))}
nonce = _gen_nonce()
signed_nonce = _gen_signed_nonce(self._ssecurity, nonce)
form["rc4_hash__"] = _gen_signature("POST", api_path, form, signed_nonce)
encrypted: dict[str, str] = {}
for key, value in form.items():
encrypted[key] = base64.b64encode(_rc4_crypt(signed_nonce, value.encode())).decode()
encrypted: dict[str, str] = {}
for key, value in form.items():
encrypted[key] = base64.b64encode(
_rc4_crypt(signed_nonce, value.encode())
).decode()
encrypted["signature"] = _gen_signature("POST", api_path, encrypted, signed_nonce)
encrypted["_nonce"] = base64.b64encode(nonce).decode()
encrypted["signature"] = _gen_signature("POST", api_path, encrypted, signed_nonce)
encrypted["_nonce"] = base64.b64encode(nonce).decode()
response = await self._client.post(
base_url + api_path,
headers={
"Cookie": self._cookies,
"Content-Type": "application/x-www-form-urlencoded",
},
content=urlencode(encrypted),
)
response.raise_for_status()
plaintext = _rc4_crypt(signed_nonce, base64.b64decode(response.text))
body = json.loads(plaintext)
if body.get("code") != 0:
raise RuntimeError(body.get("message", "unknown mi fitness error"))
return body.get("result", {})
response = await self._client.post(
base_url + api_path,
headers={
"Cookie": self._cookies,
"Content-Type": "application/x-www-form-urlencoded",
},
content=urlencode(encrypted),
)
response.raise_for_status()
plaintext = _rc4_crypt(signed_nonce, base64.b64decode(response.text))
body = json.loads(plaintext)
if body.get("code") != 0:
raise RuntimeError(body.get("message", "unknown mi fitness error"))
return body.get("result", {})
except Exception as exc:
last_error = exc
if attempt == 2:
break
await asyncio.sleep(0.5 * (attempt + 1))
raise RuntimeError(f"Mi Fitness request failed: {last_error}")
async def _fetch_key(self, key: str, start_date: str, end_date: str, region: str | None = None) -> list[dict]:
async def _fetch_key(
self, key: str, start_date: str, end_date: str, region: str | None = None
) -> list[dict]:
region_name = region or self.region
base_url = "https://hlth.io.mi.com" if region_name in ("", "cn") else f"https://{region_name}.hlth.io.mi.com"
base_url = (
"https://hlth.io.mi.com"
if region_name in ("", "cn")
else f"https://{region_name}.hlth.io.mi.com"
)
start_time = int(datetime.fromisoformat(start_date).replace(tzinfo=UTC).timestamp())
end_time = int(datetime.fromisoformat(end_date + "T23:59:59").replace(tzinfo=UTC).timestamp())
end_time = int(
datetime.fromisoformat(end_date + "T23:59:59").replace(tzinfo=UTC).timestamp()
)
next_key = None
items: list[dict] = []
@@ -183,7 +214,9 @@ class MiFitnessCloudAdapter(DataAdapter):
return items
async def _discover_region(self, preferred_region: str) -> str:
candidates = [preferred_region] + [region for region in KNOWN_REGIONS if region != preferred_region]
candidates = [preferred_region] + [
region for region in KNOWN_REGIONS if region != preferred_region
]
for region in candidates:
for key in ("weight", "steps", "heart_rate"):
try:
@@ -243,7 +276,9 @@ class MiFitnessCloudAdapter(DataAdapter):
yield
records = await self._fetch_key("steps", start_date, end_date)
daily: dict[str, dict[str, float]] = defaultdict(lambda: {"steps": 0, "distance_m": 0.0, "active_kcal": 0.0})
daily: dict[str, dict[str, float]] = defaultdict(
lambda: {"steps": 0, "distance_m": 0.0, "active_kcal": 0.0}
)
for item in records:
payload = self._parse_value(item)
date_str = self._record_datetime(item).strftime("%Y-%m-%d")
@@ -343,7 +378,5 @@ class MiFitnessCloudAdapter(DataAdapter):
)
async def close(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
self._connected = False
await self._close_client()
self._connected = False
+16 -3
View File
@@ -1,15 +1,23 @@
"""Authentication helpers for Mi Fitness cloud API."""
import logging
import keyring
logger = logging.getLogger(__name__)
SERVICE_NAME = "mi-fitness-mcp"
ACCOUNT_NAME = "mi_fitness_auth"
def save_mi_fitness_token(user_id: str, pass_token: str) -> None:
keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id", user_id)
keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token", pass_token)
try:
keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id", user_id)
keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token", pass_token)
except Exception as exc:
logger.error("Failed to save Mi Fitness credentials: %s", exc)
raise
def load_mi_fitness_token() -> tuple[str | None, str | None]:
@@ -17,7 +25,8 @@ def load_mi_fitness_token() -> tuple[str | None, str | None]:
user_id = keyring.get_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id")
pass_token = keyring.get_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token")
return user_id, pass_token
except Exception:
except Exception as exc:
logger.error("Failed to load Mi Fitness credentials: %s", exc)
return None, None
@@ -26,7 +35,11 @@ def delete_mi_fitness_token() -> None:
keyring.delete_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id")
except keyring.errors.PasswordDeleteError:
pass
except Exception as exc:
logger.error("Failed to delete Mi Fitness user_id: %s", exc)
try:
keyring.delete_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token")
except keyring.errors.PasswordDeleteError:
pass
except Exception as exc:
logger.error("Failed to delete Mi Fitness passToken: %s", exc)
+3 -4
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from typing import Literal
from platformdirs import user_config_dir, user_data_dir, user_log_dir
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
def _default_database_path() -> Path:
@@ -27,8 +27,7 @@ class Config(BaseModel):
store_raw_payloads: bool = True
default_lookback_days: int = 30
class Config:
arbitrary_types_allowed = True
model_config = ConfigDict(arbitrary_types_allowed=True)
def get_config_dir() -> Path:
@@ -48,7 +47,7 @@ def load_config() -> Config:
save_config(config)
return config
with open(config_path, "r", encoding="utf-8") as f:
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
if "database_path" in data and isinstance(data["database_path"], str):
+12 -5
View File
@@ -8,9 +8,8 @@ from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
from mi_fitness_mcp.auth import load_mi_fitness_token, save_mi_fitness_token
from mi_fitness_mcp.config import Config, get_config_path, load_config, save_config
from mi_fitness_mcp.server import main as server_main
from mi_fitness_mcp.storage import Database
from mi_fitness_mcp.services.sync_service import SyncService
from mi_fitness_mcp.storage import Database
PROGRAM_NAME = "mi-fitness-mcp"
@@ -70,7 +69,9 @@ def cmd_doctor(args):
print("✅ Mi Fitness credentials найдены")
print(f" User ID: {user_id}")
print(f" Region: {config.region}")
adapter = MiFitnessCloudAdapter(user_id=user_id, pass_token=pass_token, region=config.region)
adapter = MiFitnessCloudAdapter(
user_id=user_id, pass_token=pass_token, region=config.region
)
connected, data_types, region = asyncio.run(_check_adapter_health(adapter))
print(f" Подключение: {'✅' if connected else '❌'}")
if connected:
@@ -144,7 +145,9 @@ def cmd_sync(args):
def main():
parser = argparse.ArgumentParser(prog=PROGRAM_NAME, description="MCP server for Mi Fitness data")
parser = argparse.ArgumentParser(
prog=PROGRAM_NAME, description="MCP server for Mi Fitness data"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
subparsers.add_parser("serve", help="Run MCP server")
@@ -157,7 +160,11 @@ def main():
subparsers.add_parser("doctor", help="Check configuration and diagnose issues")
sync_parser = subparsers.add_parser("sync", help="Sync data from source")
sync_parser.add_argument("--type", choices=["daily_activity", "body_measurements", "heart_rate"], help="Type of data to sync")
sync_parser.add_argument(
"--type",
choices=["daily_activity", "body_measurements", "heart_rate"],
help="Type of data to sync",
)
sync_parser.add_argument("--start-date", help="Start date (YYYY-MM-DD)")
sync_parser.add_argument("--end-date", help="End date (YYYY-MM-DD)")
+5 -7
View File
@@ -1,6 +1,6 @@
"""Pydantic models for Mi Fitness MCP."""
from datetime import datetime
from datetime import UTC, datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -11,16 +11,14 @@ class BaseEntity(BaseModel):
id: str = Field(description="Internal UUID")
provider: Literal["mi_fitness"] = Field(description="Data provider")
source_type: Literal["cloud_session"] = Field(
description="Source of the data"
)
source_type: Literal["cloud_session"] = Field(description="Source of the data")
source_record_id: str | None = Field(None, description="Provider's native ID")
user_id: str = Field(description="User identifier")
device_id: str | None = Field(None, description="Device that recorded the data")
timezone: str = Field(default="UTC", description="Timezone for the data")
collected_at: datetime | None = Field(None, description="When data was recorded")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None))
class DailyActivity(BaseEntity):
@@ -177,7 +175,7 @@ class QueryResponse(BaseModel):
status: Literal["ok", "error"] = "ok"
source: Literal["mi_fitness_cloud", "cloud_session", "cache", "unknown"]
generated_at: datetime = Field(default_factory=datetime.utcnow)
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None))
timezone: str = "UTC"
data: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
+173 -20
View File
@@ -33,14 +33,113 @@ 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="get_data_coverage", description="Get data coverage", inputSchema={"type": "object", "properties": {"data_types": {"type": "array", "items": {"type": "string"}}}}),
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="get_data_coverage",
description="Get data coverage",
inputSchema={
"type": "object",
"properties": {"data_types": {"type": "array", "items": {"type": "string"}}},
},
),
]
@@ -74,7 +173,9 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
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()
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
@@ -87,7 +188,12 @@ async def _handle_get_connection_status() -> dict:
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()
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:
@@ -102,7 +208,12 @@ async def _handle_sync_data(arguments: dict) -> dict:
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))
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)
@@ -110,13 +221,32 @@ async def _handle_sync_data(arguments: dict) -> dict:
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}
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()
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:
@@ -133,24 +263,45 @@ async def _handle_get_daily_summary(arguments: dict) -> dict:
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()
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()
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"))
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()
return QueryResponse(
status="ok", source="cache", data={"measurements": measurements, "count": len(measurements)}
).model_dump()
async def _handle_get_data_coverage(arguments: dict) -> dict:
@@ -167,7 +318,9 @@ async def main():
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)
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:
-223
View File
@@ -1,223 +0,0 @@
"""Background sync scheduler for Mi Fitness MCP."""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Callable
from mi_fitness_mcp.adapters.base import DataAdapter
from mi_fitness_mcp.storage import Database
logger = logging.getLogger(__name__)
class SyncScheduler:
"""Scheduler for automatic background synchronization."""
def __init__(
self,
adapter: DataAdapter,
db: Database,
sync_interval_minutes: int = 60,
):
"""Initialize sync scheduler.
Args:
adapter: Data source adapter
db: Database instance
sync_interval_minutes: How often to sync (default: 1 hour)
"""
self.adapter = adapter
self.db = db
self.sync_interval = timedelta(minutes=sync_interval_minutes)
self._running = False
self._task: asyncio.Task | None = None
self._last_sync: datetime | None = None
self._sync_callback: Callable | None = None
def set_sync_callback(self, callback: Callable) -> None:
"""Set callback to be called after each sync.
Args:
callback: Function to call with sync results
"""
self._sync_callback = callback
async def start(self) -> None:
"""Start the sync scheduler."""
if self._running:
logger.warning("Scheduler already running")
return
self._running = True
self._task = asyncio.create_task(self._run_scheduler())
logger.info(f"Sync scheduler started (interval: {self.sync_interval})")
async def stop(self) -> None:
"""Stop the sync scheduler."""
if not self._running:
return
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Sync scheduler stopped")
async def _run_scheduler(self) -> None:
"""Main scheduler loop."""
from mi_fitness_mcp.services.sync_service import SyncService
sync_service = SyncService(self.adapter, self.db)
while self._running:
try:
# Check if it's time to sync
if self._should_sync():
await self._perform_sync(sync_service)
# Wait for next check
await asyncio.sleep(60) # Check every minute
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in sync scheduler: {e}")
await asyncio.sleep(60) # Wait before retrying
def _should_sync(self) -> bool:
"""Check if sync should be performed."""
if not self._last_sync:
return True
time_since_last = datetime.utcnow() - self._last_sync
return time_since_last >= self.sync_interval
async def _perform_sync(self, sync_service) -> None:
"""Perform synchronization of all data types."""
logger.info("Starting automatic sync")
results = {}
data_types = self.adapter.get_available_data_types()
for data_type in data_types:
try:
result = sync_service.sync_data_type(data_type)
results[data_type] = result
logger.info(
f"Synced {data_type}: {result['added']} added, "
f"{result['updated']} updated"
)
except Exception as e:
logger.error(f"Failed to sync {data_type}: {e}")
results[data_type] = {"error": str(e)}
self._last_sync = datetime.utcnow()
# Call callback if set
if self._sync_callback:
try:
self._sync_callback(results)
except Exception as e:
logger.error(f"Sync callback failed: {e}")
logger.info("Automatic sync completed")
def get_status(self) -> dict:
"""Get scheduler status."""
return {
"running": self._running,
"last_sync": self._last_sync.isoformat() if self._last_sync else None,
"sync_interval_minutes": self.sync_interval.total_seconds() / 60,
"next_sync": (
(self._last_sync + self.sync_interval).isoformat()
if self._last_sync
else None
),
}
class AutoSyncManager:
"""Manager for automatic synchronization configuration."""
def __init__(self, config_path: str = "config/autosync.json"):
"""Initialize auto-sync manager.
Args:
config_path: Path to autosync configuration file
"""
self.config_path = config_path
self._scheduler: SyncScheduler | None = None
def load_config(self) -> dict:
"""Load auto-sync configuration."""
import json
from pathlib import Path
path = Path(self.config_path)
if not path.exists():
return {
"enabled": False,
"sync_interval_minutes": 60,
"data_types": ["daily_activity", "sleep", "workouts"],
}
with open(path, "r") as f:
return json.load(f)
def save_config(self, config: dict) -> None:
"""Save auto-sync configuration."""
import json
from pathlib import Path
path = Path(self.config_path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(config, f, indent=2)
async def start_scheduler(
self,
adapter: DataAdapter,
db: Database,
) -> SyncScheduler | None:
"""Start scheduler with loaded configuration.
Args:
adapter: Data source adapter
db: Database instance
Returns:
SyncScheduler instance or None if not enabled
"""
config = self.load_config()
if not config.get("enabled", False):
logger.info("Auto-sync is disabled")
return None
interval = config.get("sync_interval_minutes", 60)
self._scheduler = SyncScheduler(
adapter=adapter,
db=db,
sync_interval_minutes=interval,
)
await self._scheduler.start()
return self._scheduler
async def stop_scheduler(self) -> None:
"""Stop the running scheduler."""
if self._scheduler:
await self._scheduler.stop()
self._scheduler = None
def get_scheduler_status(self) -> dict | None:
"""Get current scheduler status."""
if not self._scheduler:
return None
return self._scheduler.get_status()
+36 -26
View File
@@ -1,5 +1,6 @@
"""Query service for retrieving data from database."""
from contextlib import suppress
from datetime import datetime, timedelta
from typing import Any
@@ -65,10 +66,12 @@ class QueryService:
for summary in summaries:
value = summary.get(metric)
if value is not None:
series.append({
"date": summary["date"],
"value": value,
})
series.append(
{
"date": summary["date"],
"value": value,
}
)
# Apply aggregation if needed
if granularity == "week":
@@ -175,10 +178,9 @@ class QueryService:
# Parse stages if available
if record.get("stages"):
import json
try:
with suppress(json.JSONDecodeError):
session["stages"] = json.loads(record["stages"])
except json.JSONDecodeError:
pass
sessions.append(session)
@@ -198,9 +200,10 @@ class QueryService:
workouts = []
for record in records:
# Apply filters
if activity_types:
if record["activity_type"].lower() not in [t.lower() for t in activity_types]:
continue
if activity_types and record["activity_type"].lower() not in [
t.lower() for t in activity_types
]:
continue
if min_duration and record.get("duration_minutes", 0) < min_duration:
continue
@@ -210,20 +213,22 @@ class QueryService:
if distance_km < min_distance_km:
continue
workouts.append({
"workout_id": record["workout_id"],
"activity_type": record["activity_type"],
"start_at": record["start_at"],
"end_at": record["end_at"],
"duration_minutes": record["duration_minutes"],
"distance_m": record.get("distance_m"),
"calories_kcal": record.get("calories_kcal"),
"avg_heart_rate_bpm": record.get("avg_heart_rate_bpm"),
"max_heart_rate_bpm": record.get("max_heart_rate_bpm"),
"avg_pace_sec_per_km": record.get("avg_pace_sec_per_km"),
"max_pace_sec_per_km": record.get("max_pace_sec_per_km"),
"total_steps": record.get("total_steps"),
})
workouts.append(
{
"workout_id": record["workout_id"],
"activity_type": record["activity_type"],
"start_at": record["start_at"],
"end_at": record["end_at"],
"duration_minutes": record["duration_minutes"],
"distance_m": record.get("distance_m"),
"calories_kcal": record.get("calories_kcal"),
"avg_heart_rate_bpm": record.get("avg_heart_rate_bpm"),
"max_heart_rate_bpm": record.get("max_heart_rate_bpm"),
"avg_pace_sec_per_km": record.get("avg_pace_sec_per_km"),
"max_pace_sec_per_km": record.get("max_pace_sec_per_km"),
"total_steps": record.get("total_steps"),
}
)
return workouts
@@ -245,8 +250,13 @@ class QueryService:
# Add optional metrics
optional_fields = [
"bmi", "body_fat_pct", "muscle_mass_kg", "water_pct",
"bone_mass_kg", "visceral_fat_score", "basal_metabolism_kcal",
"bmi",
"body_fat_pct",
"muscle_mass_kg",
"water_pct",
"bone_mass_kg",
"visceral_fat_score",
"basal_metabolism_kcal",
"metabolic_age",
]
+3 -9
View File
@@ -2,8 +2,8 @@
import asyncio
import logging
from collections.abc import AsyncIterator, Iterator
from datetime import datetime, timedelta
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
from mi_fitness_mcp.adapters.base import DataAdapter
@@ -65,13 +65,7 @@ class SyncService:
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
if last_record_ts:
start_date = last_record_ts.strftime("%Y-%m-%d")
elif self.adapter.__class__.__name__ == "MiFitnessCloudAdapter":
start_date = "2020-01-01"
else:
start = datetime.strptime(end_date, "%Y-%m-%d") - timedelta(days=30)
start_date = start.strftime("%Y-%m-%d")
start_date = last_record_ts.strftime("%Y-%m-%d") if last_record_ts else "2020-01-01"
added = 0
updated = 0
+30 -20
View File
@@ -540,10 +540,12 @@ class Database:
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "daily_activity",
**dict(row),
})
results.append(
{
"data_type": "daily_activity",
**dict(row),
}
)
# Sleep coverage
row = conn.execute(
@@ -558,10 +560,12 @@ class Database:
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "sleep",
**dict(row),
})
results.append(
{
"data_type": "sleep",
**dict(row),
}
)
# Workouts coverage
row = conn.execute(
@@ -576,10 +580,12 @@ class Database:
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "workouts",
**dict(row),
})
results.append(
{
"data_type": "workouts",
**dict(row),
}
)
# Body measurements coverage
row = conn.execute(
@@ -594,10 +600,12 @@ class Database:
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "body_measurements",
**dict(row),
})
results.append(
{
"data_type": "body_measurements",
**dict(row),
}
)
row = conn.execute(
"""
@@ -611,9 +619,11 @@ class Database:
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "heart_rate",
**dict(row),
})
results.append(
{
"data_type": "heart_rate",
**dict(row),
}
)
return results
+14
View File
@@ -0,0 +1,14 @@
from mi_fitness_mcp.config import Config, load_config, save_config
def test_config_roundtrip(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
monkeypatch.setattr("mi_fitness_mcp.config.get_config_path", lambda: config_file)
config = Config(mode="mi_fitness_cloud", region="ru")
save_config(config)
loaded = load_config()
assert loaded.mode == "mi_fitness_cloud"
assert loaded.region == "ru"
assert loaded.database_path.name == "mi_fitness.db"
+65
View File
@@ -0,0 +1,65 @@
import pytest
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
async def _collect(async_iterable):
items = []
async for item in async_iterable:
items.append(item)
return items
def test_optional_number_helpers():
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
assert adapter._optional_float(0) is None
assert adapter._optional_float("1.5") == 1.5
assert adapter._optional_int(0) is None
assert adapter._optional_int("7") == 7
def test_parse_value_dict_and_json():
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
assert adapter._parse_value({"value": {"steps": 1}}) == {"steps": 1}
assert adapter._parse_value({"value": '{"steps": 2}'}) == {"steps": 2}
def test_record_datetime_uses_zone_offset():
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
dt = adapter._record_datetime({"time": 0, "zone_offset": 10800})
assert dt.isoformat().startswith("1970-01-01T03:00:00")
@pytest.mark.asyncio
async def test_iter_daily_activity_aggregates_steps_and_calories(monkeypatch):
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
adapter._connected = True
adapter._client = object()
async def fake_fetch(key, start_date, end_date, region=None):
if key == "steps":
return [
{
"time": 1743467400,
"zone_offset": 0,
"value": '{"steps": 10, "distance": 8, "calories": 1}',
},
{
"time": 1743467460,
"zone_offset": 0,
"value": '{"steps": 20, "distance": 16, "calories": 2}',
},
]
if key == "calories":
return [
{"time": 1743467400, "zone_offset": 0, "value": '{"calories": 5}'},
{"time": 1743467460, "zone_offset": 0, "value": '{"calories": 7}'},
]
return []
monkeypatch.setattr(adapter, "_fetch_key", fake_fetch)
items = await _collect(adapter.iter_daily_activity("2025-04-01", "2025-04-01"))
assert len(items) == 1
assert items[0].steps == 30
assert items[0].distance_m == 24
assert items[0].active_kcal == 12
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime
from mi_fitness_mcp.models import BodyMeasurement, DailyActivity, HeartRateSample
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.storage import Database
def test_storage_and_query_roundtrip(tmp_path):
db = Database(tmp_path / "test.db")
activity = DailyActivity(
id="a1",
provider="mi_fitness",
source_type="cloud_session",
user_id="u1",
date="2025-04-01",
steps=1000,
distance_m=800,
active_kcal=50,
)
hr = HeartRateSample(
id="hr1",
provider="mi_fitness",
source_type="cloud_session",
user_id="u1",
timestamp=datetime(2025, 4, 1, 12, 0, 0),
bpm=70,
sample_type="passive",
)
body = BodyMeasurement(
id="w1",
provider="mi_fitness",
source_type="cloud_session",
user_id="u1",
timestamp=datetime(2025, 4, 1, 7, 0, 0),
weight_kg=101.5,
bmi=28.0,
)
db.insert_daily_activity(activity)
db.insert_heart_rate_sample(hr)
db.insert_body_measurement(body)
query = QueryService(db, "u1")
assert len(query.get_daily_summaries("2025-04-01", "2025-04-01")) == 1
assert len(query.get_heart_rate_samples("2025-04-01", "2025-04-01")) == 1
assert len(query.get_body_measurements("2025-04-01", "2025-04-01")) == 1