Files
mi-fitness-health/deploy/readonly.py
T

99 lines
4.2 KiB
Python

"""受限只读 MCP:复用查询服务,数据库与凭据表双重隔离。"""
import os
import sqlite3
from contextlib import contextmanager
from datetime import date
from pathlib import Path
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.storage import Database
TABLES = {'daily_activity', 'sleep_sessions', 'body_measurements', 'heart_rate_samples',
'workouts', 'spo2_samples', 'stress_samples', 'abnormal_heart_beat_events'}
METHODS = {'daily_activity': 'get_daily_summaries', 'sleep': 'get_sleep_sessions',
'body_measurements': 'get_body_measurements', 'heart_rate': 'get_heart_rate_samples',
'workouts': 'get_workouts', 'spo2': 'get_spo2_samples', 'stress': 'get_stress_samples',
'abnormal_heart_beat': 'get_abnormal_heart_beat_events'}
PRIVATE_FIELDS = {'user_id', 'device_id', 'id', 'source_record_id', 'sleep_id', 'workout_id', 'event_id'}
class ReadOnlyDatabase(Database):
def __init__(self, path):
self.db_path = Path(path).resolve()
@contextmanager
def _get_connection(self):
conn = sqlite3.connect(self.db_path.as_uri() + '?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA query_only=ON')
def authorize(action, table, column, database, trigger):
if action == sqlite3.SQLITE_READ:
return sqlite3.SQLITE_OK if table in TABLES else sqlite3.SQLITE_DENY
if action in (sqlite3.SQLITE_SELECT, sqlite3.SQLITE_FUNCTION, sqlite3.SQLITE_RECURSIVE):
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
conn.set_authorizer(authorize)
try:
yield conn
finally:
conn.close()
def strip_private(value):
if isinstance(value, dict):
return {k: strip_private(v) for k, v in value.items() if k not in PRIVATE_FIELDS}
if isinstance(value, list):
return [strip_private(v) for v in value]
return value
def query_records(path, kind, start_date, end_date):
if kind not in METHODS:
raise ValueError('不支持的健康数据类型')
start, end = date.fromisoformat(start_date), date.fromisoformat(end_date)
if not 0 <= (end - start).days < 366:
raise ValueError('单次查询范围必须为 1 至 366 天')
db = ReadOnlyDatabase(path)
with db._get_connection() as conn:
users = set()
for table in sorted(TABLES):
users.update(r[0] for r in conn.execute('SELECT DISTINCT user_id FROM ' + table))
if len(users) != 1:
raise ValueError('数据库中需要恰好一个健康数据账号')
service = QueryService(db, next(iter(users)))
records = getattr(service, METHODS[kind])(start.isoformat(), end.isoformat())
if len(records) > 2000:
raise ValueError('结果超过 2000 条,请缩小日期范围;未截断或省略数据')
return strip_private(records)
def main(transport='stdio'):
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
database = Path(os.environ.get('HEALTH_READONLY_DB', '/health/mi_fitness.db'))
mcp = FastMCP('NAS Health Read Only', host='0.0.0.0', port=8323, log_level='WARNING',
stateless_http=True, json_response=True,
transport_security=TransportSecuritySettings(
allowed_hosts=['localhost:*', '127.0.0.1:*', 'readonly:8323'],
allowed_origins=[]))
@mcp.tool(annotations={'readOnlyHint': True, 'destructiveHint': False, 'openWorldHint': False})
def get_health_records(kind: str, start_date: str, end_date: str) -> dict:
"""查询 NAS 已保存的健康记录,不触发同步。日期为北京时间 YYYY-MM-DD。
kind: daily_activity、sleep、body_measurements、heart_rate、workouts、spo2、stress、abnormal_heart_beat。
单次最多 366 天和 2000 条,超限请缩小范围。单位:步、米、千卡、分钟、kg、bpm、百分比。
"""
records = query_records(database, kind, start_date, end_date)
return {'timezone': 'Asia/Shanghai', 'kind': kind, 'count': len(records), 'data': records}
mcp.run(transport=transport)
if __name__ == '__main__':
main()