mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-19 03:33:46 +08:00
保存 NAS 健康系统 UI7 代码与本机维护基线
This commit is contained in:
1 parent
1767b4921a
commit
8546ba23ff
80 files changed
+4984
-381
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# 保留已授权的局域网绑定,为网页单独设置访问密码。
|
||||
services:
|
||||
backend:
|
||||
image: local/mi-fitness-nas:20261017-audit3
|
||||
web:
|
||||
image: local/mi-fitness-nas:20261017-ui7
|
||||
environment:
|
||||
MI_FITNESS_CALENDAR_DIR: /calendar
|
||||
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201
|
||||
MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码}
|
||||
volumes:
|
||||
- ./calendar:/calendar
|
||||
ports: !override
|
||||
- '127.0.0.1:18322:8322'
|
||||
- '192.168.31.201:18322:8322'
|
||||
worker:
|
||||
image: local/mi-fitness-nas:20261017-audit3
|
||||
@@ -0,0 +1,79 @@
|
||||
# 长期运行配置:仅回环访问;不修改现有容器或 Tailscale。
|
||||
name: mi-fitness-health
|
||||
services:
|
||||
backend:
|
||||
image: local/mi-fitness-nas:20261016
|
||||
command: ["api"]
|
||||
environment:
|
||||
MI_FITNESS_ADMIN_KEY: ${MI_FITNESS_ADMIN_KEY:?需在本地生成管理员密钥}
|
||||
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,backend
|
||||
volumes:
|
||||
- ./state:/state
|
||||
- ./secrets/keyring_master:/run/secrets/keyring_master:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8321/', timeout=3).close()"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
user: '1000:1001'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
mem_limit: 512m
|
||||
cpus: 0.5
|
||||
restart: unless-stopped
|
||||
web:
|
||||
image: local/mi-fitness-nas:20261016
|
||||
command: ["web"]
|
||||
environment:
|
||||
MI_FITNESS_ADMIN_KEY: ${MI_FITNESS_ADMIN_KEY:?需在本地生成管理员密钥}
|
||||
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1
|
||||
ports:
|
||||
- '127.0.0.1:18322:8322'
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
user: '1000:1001'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
mem_limit: 256m
|
||||
cpus: 0.5
|
||||
restart: unless-stopped
|
||||
worker:
|
||||
image: local/mi-fitness-nas:20261016
|
||||
command: ["worker"]
|
||||
volumes:
|
||||
- ./state:/state
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
user: '1000:1001'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
mem_limit: 192m
|
||||
cpus: 0.25
|
||||
restart: unless-stopped
|
||||
readonly:
|
||||
image: local/mi-fitness-nas:20261016
|
||||
command: ["mcp"]
|
||||
profiles: [chatgpt]
|
||||
volumes:
|
||||
- ./state/data:/health:ro
|
||||
user: '1000:1001'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
mem_limit: 256m
|
||||
cpus: 0.5
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM local/mi-fitness-nas:20261017-audit3
|
||||
USER root
|
||||
COPY overlay/ /src/src/mi_fitness_mcp/
|
||||
RUN python -c "import shutil,sysconfig; shutil.copytree('/src/src/mi_fitness_mcp',sysconfig.get_paths()['purelib']+'/mi_fitness_mcp',dirs_exist_ok=True)"
|
||||
USER 1000:1001
|
||||
@@ -0,0 +1,36 @@
|
||||
"""实际部署验证:从运行容器内部读取鉴权配置,不输出秘密。"""
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
item=json.loads(subprocess.check_output(['docker','inspect','mi-fitness-health-web-1']))[0]
|
||||
assert item['Config']['Image']=='local/mi-fitness-nas:20261017-ui7','网页镜像版本不符'
|
||||
env=dict(line.split('=',1) for line in item['Config']['Env'])
|
||||
token=base64.b64encode(('admin:'+env['MI_FITNESS_WEB_PASSWORD']).encode('utf-8')).decode('ascii')
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def fetch(path,auth=True):
|
||||
request=urllib.request.Request('http://127.0.0.1:18322'+path,headers={'Authorization':'Basic '+token} if auth else {})
|
||||
with opener.open(request,timeout=5) as response:return response.status,response.read(2000000).decode('utf-8')
|
||||
for attempt in range(30):
|
||||
try:
|
||||
status,html=fetch('/')
|
||||
assert status==200 and '20261017-ui7' in html
|
||||
for path,marker in [('/static/js/health-views.js','HealthViews'),('/static/js/health-ui.js','loadHealthView'),('/static/css/health.css','health-cards')]:
|
||||
assert marker in fetch(path)[1]
|
||||
break
|
||||
except (OSError,ValueError,AssertionError):
|
||||
if attempt==29:raise RuntimeError('网页版本或资源就绪验证失败') from None
|
||||
time.sleep(2)
|
||||
try:
|
||||
fetch('/',False)
|
||||
except urllib.error.HTTPError as exc:
|
||||
assert exc.code==401,'匿名访问权限异常'
|
||||
else:
|
||||
raise RuntimeError('匿名访问未被拒绝')
|
||||
assert json.loads(fetch('/api/calendar?start=2026&end=2026')[1])['years']['2026']['days']
|
||||
assert env.get('MI_FITNESS_CALENDAR_DIR')=='/calendar'
|
||||
assert any(m['Destination']=='/calendar' and m['RW'] for m in item['Mounts'])
|
||||
print('新版网页与三项新增资源就绪;当前自设密码验证通过,匿名访问仍需认证。')
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
[ "$(id -u)" = 0 ] || { echo '请通过 sudo 执行。'; exit 1; }
|
||||
package=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
target=/vol2/1000/Soft/mi-fitness-health
|
||||
cd "$package"
|
||||
sha256sum -c health-ui7.tar.sha256
|
||||
python3 - "$target/compose.override.yml" "$package/expected-current.sha256" <<'PY'
|
||||
import hashlib,sys
|
||||
from pathlib import Path
|
||||
assert hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()==Path(sys.argv[2]).read_text().strip(), '现有配置已变化,停止避免覆盖'
|
||||
PY
|
||||
before=$(docker inspect --format '{{.Id}}' mi-fitness-health-backend-1 mi-fitness-health-worker-1)
|
||||
[ ! -L "$target/calendar" ] || { echo "日历目录不能是符号链接"; exit 1; }
|
||||
mkdir -p "$target/calendar"
|
||||
chown 1000:1001 "$target/calendar"
|
||||
chmod 700 "$target/calendar"
|
||||
docker load -i "$package/health-ui7.tar"
|
||||
cd "$target"
|
||||
docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f "$package/compose.override.yml" config --quiet
|
||||
old="$target/compose.override.pre-ui7-$(date +%Y%m%d-%H%M%S).yml"
|
||||
cp compose.override.yml "$old"
|
||||
complete=0
|
||||
recover() {
|
||||
if [ "$complete" = 0 ]; then
|
||||
cp "$old" "$target/compose.override.yml"
|
||||
chown 1000:1001 "$target/compose.override.yml"
|
||||
chmod 600 "$target/compose.override.yml"
|
||||
docker compose --project-name mi-fitness-health up -d --no-deps --no-build --pull never web || true
|
||||
echo '新版未通过验证,已恢复原网页配置并尝试回退。'
|
||||
fi
|
||||
}
|
||||
trap recover EXIT
|
||||
cp "$package/compose.override.yml" compose.override.yml
|
||||
chown 1000:1001 compose.override.yml
|
||||
chmod 600 compose.override.yml
|
||||
docker compose --project-name mi-fitness-health up -d --no-deps --no-build --pull never --wait --wait-timeout 60 web
|
||||
python3 "$package/check-web.py"
|
||||
after=$(docker inspect --format '{{.Id}}' mi-fitness-health-backend-1 mi-fitness-health-worker-1)
|
||||
[ "$before" = "$after" ] || { echo '后端或同步容器标识发生变化,停止验收。'; exit 1; }
|
||||
complete=1
|
||||
trap - EXIT
|
||||
printf '\n网页更新完成。后端和 worker 未重建,登录密码及数据目录未修改。\n'
|
||||
@@ -0,0 +1,98 @@
|
||||
"""受限只读 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()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""部署补充:复用上游同步与查询,增加归档、凭据保护及定时维护。"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from contextlib import closing
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
STATE = Path(os.environ.get('HEALTH_STATE', '/state'))
|
||||
HEALTH_PATHS = {
|
||||
'/app/v1/data/get_fitness_data_by_time',
|
||||
'/app/v1/data/get_aggregated_fitness_data_by_time',
|
||||
'/app/v1/data/get_sport_records_by_time',
|
||||
}
|
||||
|
||||
|
||||
def archive_response(directory, api_path, arguments, result):
|
||||
if api_path not in HEALTH_PATHS:
|
||||
raise ValueError('拒绝归档非健康数据接口')
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
record = {'received_at': now.isoformat(), 'path': api_path, 'arguments': arguments, 'result': result}
|
||||
with gzip.open(directory / (now.date().isoformat() + '.jsonl.gz'), 'at', encoding='utf-8') as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False, separators=(',', ':')) + '\n')
|
||||
|
||||
|
||||
def sync_start(state, today):
|
||||
# 留三天重叠窗口;离线后从上次完整成功前开始补齐,不只取最近几天。
|
||||
last = state.get('last_success_date')
|
||||
return (date.fromisoformat(last) - timedelta(days=2) if last else today - timedelta(days=29)).isoformat()
|
||||
|
||||
|
||||
def finish_sync(state, result, today):
|
||||
state['last_attempt_at'] = datetime.now(timezone.utc).isoformat()
|
||||
state['status'] = result.get('status', 'error')
|
||||
if result.get('status') == 'ok':
|
||||
state['last_success_date'] = today.isoformat()
|
||||
state.pop('error_type', None)
|
||||
|
||||
|
||||
def backup_database(source, directory):
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
|
||||
target = directory / ('health-' + stamp + '.sqlite')
|
||||
pending = target.with_suffix('.pending')
|
||||
with closing(sqlite3.connect(Path(source).resolve().as_uri() + '?mode=ro', uri=True)) as src:
|
||||
with closing(sqlite3.connect(pending)) as dst:
|
||||
src.backup(dst)
|
||||
dst.execute('PRAGMA secure_delete=ON')
|
||||
if dst.execute("SELECT 1 FROM sqlite_master WHERE name='api_keys'").fetchone():
|
||||
dst.execute('DELETE FROM api_keys')
|
||||
dst.commit()
|
||||
dst.execute('VACUUM')
|
||||
if dst.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
|
||||
raise RuntimeError('备份完整性检查失败')
|
||||
pending.replace(target)
|
||||
# 保留最近 30 份每日备份;只处理本程序命名的备份文件。
|
||||
for old in sorted(directory.glob('health-*.sqlite'), reverse=True)[30:]:
|
||||
old.unlink()
|
||||
return target
|
||||
|
||||
|
||||
def setup_credentials():
|
||||
import keyring
|
||||
from keyrings.cryptfile.cryptfile import CryptFileKeyring
|
||||
from mi_fitness_mcp import security
|
||||
|
||||
master_file = Path(os.environ.get('HEALTH_KEY_FILE', '/run/secrets/keyring_master'))
|
||||
master = master_file.read_text(encoding='utf-8').strip()
|
||||
if len(master) < 32:
|
||||
raise RuntimeError('缺少有效的独立密钥环解锁文件')
|
||||
ring = CryptFileKeyring()
|
||||
ring.file_path = str(STATE / 'credentials' / 'encrypted-keyring.cfg')
|
||||
ring.keyring_key = master
|
||||
keyring.set_keyring(ring)
|
||||
|
||||
def encrypted_store(api_key, token):
|
||||
# 加密失败直接中止,不沿用上游降级为 SQLite 明文的行为。
|
||||
keyring.set_password(security.SERVICE_NAME, security._account_for(api_key), token)
|
||||
return True
|
||||
|
||||
security.store_api_key_secret = encrypted_store
|
||||
|
||||
|
||||
def enable_archive():
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
|
||||
original = MiFitnessCloudAdapter._request
|
||||
|
||||
async def request_and_archive(self, base_url, api_path, payload):
|
||||
result = await original(self, base_url, api_path, payload)
|
||||
archive_response(STATE / 'raw', api_path, payload, result)
|
||||
return result
|
||||
|
||||
MiFitnessCloudAdapter._request = request_and_archive
|
||||
|
||||
|
||||
def load_single_key(db_path):
|
||||
with closing(sqlite3.connect(Path(db_path).resolve().as_uri() + '?mode=ro', uri=True)) as c:
|
||||
rows = c.execute('SELECT key,user_id,region FROM api_keys WHERE revoked=0 ORDER BY created_at DESC,rowid DESC').fetchall()
|
||||
if not rows or len({(row[1], row[2]) for row in rows}) != 1:
|
||||
raise RuntimeError('仅允许一个账号和地区的有效授权')
|
||||
return rows[0][0]
|
||||
|
||||
|
||||
def atomic_json(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pending = path.with_suffix('.pending')
|
||||
pending.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
pending.replace(path)
|
||||
|
||||
|
||||
def worker():
|
||||
import httpx
|
||||
|
||||
state_file = STATE / 'automation' / 'status.json'
|
||||
state = json.loads(state_file.read_text(encoding='utf-8')) if state_file.exists() else {}
|
||||
database = STATE / 'data' / 'mi_fitness.db'
|
||||
while True:
|
||||
today = datetime.now(timezone(timedelta(hours=8))).date()
|
||||
try:
|
||||
if database.exists() and state.get('backup_date') != today.isoformat():
|
||||
backup_database(database, STATE / 'backups')
|
||||
state['backup_date'] = today.isoformat()
|
||||
key = load_single_key(database)
|
||||
with httpx.Client(base_url='http://backend:8321', headers={'X-API-Key': key}, timeout=20, trust_env=False) as client:
|
||||
response = client.post('/api/sync', json={
|
||||
'start_date': sync_start(state, today), 'end_date': today.isoformat(), 'background': True,
|
||||
})
|
||||
response.raise_for_status()
|
||||
task = response.json()['sync_id']
|
||||
deadline = time.monotonic() + 2400
|
||||
while time.monotonic() < deadline:
|
||||
response = client.get('/api/sync/' + task)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if result.get('status') not in ('accepted', 'queued', 'running'):
|
||||
finish_sync(state, result, today)
|
||||
break
|
||||
time.sleep(10)
|
||||
else:
|
||||
finish_sync(state, {'status': 'timeout'}, today)
|
||||
except Exception as exc:
|
||||
# 不将云端错误正文、账号标识或认证参数写入日志。
|
||||
finish_sync(state, {'status': 'error'}, today)
|
||||
state['error_type'] = type(exc).__name__
|
||||
atomic_json(state_file, state)
|
||||
print(json.dumps({'event': 'maintenance', 'status': state.get('status'), 'backup_date': state.get('backup_date')}), flush=True)
|
||||
time.sleep(1800 if state.get('status') == 'ok' else 300)
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o077)
|
||||
mode = sys.argv[1]
|
||||
if mode == 'api':
|
||||
setup_credentials()
|
||||
enable_archive()
|
||||
import uvicorn
|
||||
from mi_fitness_mcp.api import app
|
||||
uvicorn.run(app, host='0.0.0.0', port=8321, access_log=False)
|
||||
elif mode == 'web':
|
||||
logging.getLogger('werkzeug').disabled = True
|
||||
from mi_fitness_mcp.web import create_app
|
||||
app = create_app(backend_url='http://backend:8321', admin_key=os.environ['MI_FITNESS_ADMIN_KEY'])
|
||||
app.run(host='0.0.0.0', port=8322, debug=False, use_reloader=False)
|
||||
elif mode == 'worker':
|
||||
worker()
|
||||
elif mode == 'mcp':
|
||||
from readonly import main as readonly_main
|
||||
readonly_main('streamable-http')
|
||||
else:
|
||||
raise ValueError('未知运行模式')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user