feat(M0): MCP 手机控制 Server 骨架——FastMCP HTTP 传输(8033),平台登录会话+CSRF 封装,de_list_devices/de_screenshot(图像块)/de_tap/de_swipe,写门控/白名单/审计,实测全链路通过

This commit is contained in:
2026-09-04 12:42:16 +08:00
parent c304996c7b
commit 2ba9f0c239
6 changed files with 316 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""MCP 手机控制 Server 包。"""
+27
View File
@@ -0,0 +1,27 @@
"""审计日志:每次 MCP 调用记录一行(含只读)。"""
import json
import os
from datetime import datetime
_file = None
def init(path):
global _file
os.makedirs(os.path.dirname(path), exist_ok=True)
_file = open(path, "a", encoding="utf-8")
audit("system", "startup", "", "")
def audit(tool, serial, args_summary, result):
global _file
if _file is None:
return
rec = {"ts": datetime.now().isoformat(timespec="seconds"),
"tool": tool, "serial": serial,
"args": str(args_summary)[:200], "result": str(result)[:200]}
try:
_file.write(json.dumps(rec, ensure_ascii=False) + "\n")
_file.flush()
except Exception:
pass
+32
View File
@@ -0,0 +1,32 @@
"""MCP Server 配置(环境变量,生产用 .env 注入)。"""
import os
def _env(key, default):
return os.environ.get(key, default)
class Settings:
# 平台(auto_control)地址与账号
platform_url = _env("MCP_PLATFORM_URL", "http://127.0.0.1:18050")
platform_user = _env("MCP_PLATFORM_USER", "admin")
platform_pass = _env("MCP_PLATFORM_PASS", "")
# 安全
allow_write = _env("MCP_ALLOW_WRITE", "0") == "1" # 写操作门控(默认只读)
allowed_serials = [s.strip() for s in
_env("MCP_ALLOWED_SERIALS", "").split(",") if s.strip()] # 空=不限
# 传输
http_host = _env("MCP_HTTP_HOST", "0.0.0.0")
http_port = int(_env("MCP_HTTP_PORT", "8033"))
# 截图
screenshot_width = int(_env("MCP_SCREENSHOT_WIDTH", "540"))
jpeg_quality = int(_env("MCP_JPEG_QUALITY", "70"))
# 审计
audit_file = _env("MCP_AUDIT_FILE", "/var/log/mcp/audit.log")
# 平台请求超时(秒)
platform_timeout = float(_env("MCP_PLATFORM_TIMEOUT", "30"))
+146
View File
@@ -0,0 +1,146 @@
"""MCP 手机控制 Server(M0:设备列表/截图/点击/滑动)。
运行:MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server
客户端:Streamable HTTP @ http://<host>:8033/mcp
"""
import base64
import io
import logging
from fastmcp import FastMCP
from PIL import Image
from mcp_server import audit, config
from mcp_server.platform_client import PlatformClient, PlatformError
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
_log = logging.getLogger("mcp")
S = config.Settings()
audit.init(S.audit_file)
mcp = FastMCP("mobile-control")
_platform = None
def platform():
"""惰性初始化平台客户端(启动即登录,失败明确报错)。"""
global _platform
if _platform is None:
_platform = PlatformClient(S.platform_url, S.platform_user,
S.platform_pass, S.platform_timeout)
return _platform
def _check_serial(serial):
"""白名单校验:未配置时限制为平台设备池(enabled)设备。"""
if not serial:
raise PlatformError("invalid_param", "缺少 serial")
if S.allowed_serials and serial not in S.allowed_serials:
raise PlatformError("device_not_allowed", f"设备 {serial} 不在白名单")
return serial
def _check_write():
if not S.allow_write:
raise PlatformError("write_disabled", "写操作未启用(MCP_ALLOW_WRITE=1 开启)")
def _err(e: PlatformError):
return {"ok": False, "error": {"code": e.code, "message": e.message}}
def _ok(data):
return {"ok": True, "data": data}
@mcp.tool()
def de_list_devices() -> dict:
"""列出可控制设备:serial/在线状态/型号/任务状态/前台 App。
返回 [{serial, model, online, task_job, worker_status, foreground_app}]。
"""
try:
devs = platform().list_devices()
except PlatformError as e:
return _err(e)
out = []
for d in devs:
out.append({
"serial": d.get("serial"),
"model": d.get("model") or d.get("device_name") or "",
"online": bool(d.get("present")),
"task_job": d.get("task_job") or "",
"worker_status": d.get("worker_status") or "idle",
"foreground_app": d.get("foreground_app") or "",
})
audit.audit("de_list_devices", "", "", f"{len(out)} 台")
return _ok(out)
@mcp.tool()
def de_screenshot(serial: str) -> dict:
"""截取设备屏幕并返回图像(image/jpeg,宽 ≤540px)。
同时返回 {width, height, screen_state}。多模态客户端可直接看图。
"""
try:
serial = _check_serial(serial)
jpeg, screen_state = platform().screenshot(serial)
img = Image.open(io.BytesIO(jpeg))
w, h = img.size
if w > S.screenshot_width:
ratio = S.screenshot_width / w
img = img.resize((S.screenshot_width, int(h * ratio)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=S.jpeg_quality)
data = base64.b64encode(buf.getvalue()).decode()
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"截图处理失败: {e}"))
audit.audit("de_screenshot", serial, f"{w}x{h}", "ok")
return _ok({"image": {"type": "image", "data": data,
"mimeType": "image/jpeg"},
"width": w, "height": h,
"screen_state": screen_state})
@mcp.tool()
def de_tap(serial: str, x: int, y: int) -> dict:
"""点击设备屏幕指定坐标(设备原生分辨率像素,与截图 1:1 换算)。"""
try:
_check_write()
serial = _check_serial(serial)
if x < 0 or y < 0:
raise PlatformError("invalid_param", "坐标不能为负")
platform().tap(serial, x, y)
except PlatformError as e:
return _err(e)
audit.audit("de_tap", serial, f"({x},{y})", "ok")
return _ok({"action": "tap", "serial": serial, "x": x, "y": y})
@mcp.tool()
def de_swipe(serial: str, x1: int, y1: int, x2: int, y2: int,
duration: float = 0.2) -> dict:
"""在设备屏幕上滑动(起终点为原生分辨率像素)。"""
try:
_check_write()
serial = _check_serial(serial)
platform().swipe(serial, x1, y1, x2, y2, duration)
except PlatformError as e:
return _err(e)
audit.audit("de_swipe", serial, f"({x1},{y1})->({x2},{y2})", "ok")
return _ok({"action": "swipe", "serial": serial})
def main():
_log.info("MCP mobile-control 启动: allow_write=%s port=%s",
S.allow_write, S.http_port)
mcp.run(transport="http", host=S.http_host, port=S.http_port)
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
"""平台(auto_control)HTTP 客户端:登录会话 + CSRF + API 封装。
会话失效自动重登;CSRF token 每次登录后获取,POST 必带。
只读接口(GET)与写接口(POST)分离封装,写操作由 MCP 门控层决定是否调用。
"""
import httpx
import logging
_log = logging.getLogger("mcp.platform")
_CSRF_HEADER = "X-CSRF-Token"
class PlatformError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
class PlatformClient:
def __init__(self, url, user, password, timeout=30.0):
self.url = url.rstrip("/")
self.user = user
self.password = password
self._client = httpx.Client(base_url=self.url, timeout=timeout,
follow_redirects=True)
self._csrf = ""
self._login()
# ---------- 会话 ----------
def _login(self):
"""登录平台,拿会话 cookie + CSRF token。"""
r = self._client.post("/login", data={
"username": self.user, "password": self.password})
if r.status_code != 302 and r.status_code != 200:
raise PlatformError("platform_unavailable",
f"平台登录失败 HTTP {r.status_code}: {r.text[:120]}")
csrf = self._client.get("/api/csrf")
if csrf.status_code == 200:
self._csrf = (csrf.json() or {}).get("token", "")
_log.info("平台登录成功: %s", self.user)
def _ensure_session(self):
"""会话过期(401/403/302 到登录页)时重登。"""
r = self._client.get("/api/status")
if r.status_code in (401, 403) or r.url.path.endswith("/login"):
_log.warning("会话失效,重新登录")
self._login()
return True
return False
# ---------- 基础请求 ----------
def _get(self, path, **params):
self._ensure_session()
return self._client.get(path, params=params)
def _post(self, path, json=None):
self._ensure_session()
headers = {_CSRF_HEADER: self._csrf} if self._csrf else {}
return self._client.post(path, json=json or {}, headers=headers)
# ---------- 平台 API 封装(M0) ----------
def list_devices(self):
"""设备列表与状态(GET /api/status)。"""
r = self._get("/api/status")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/status HTTP {r.status_code}")
j = r.json() or {}
return j.get("devices", [])
def screenshot(self, serial):
"""截图(GET /api/screen/thumb),返回 (jpeg_bytes, screen_state)。"""
r = self._get("/api/screen/thumb", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"截图 HTTP {r.status_code}")
return r.content, r.headers.get("X-Screen-State", "unknown")
def tap(self, serial, x, y):
"""点击(POST /api/screen/tap)。"""
r = self._post("/api/screen/tap", json={"serial": serial,
"x": int(x), "y": int(y)})
return self._check_op(r, "tap")
def swipe(self, serial, x1, y1, x2, y2, duration=0.2):
"""滑动(POST /api/screen/swipe)。"""
r = self._post("/api/screen/swipe", json={
"serial": serial, "x1": int(x1), "y1": int(y1),
"x2": int(x2), "y2": int(y2),
"duration": float(duration)})
return self._check_op(r, "swipe")
@staticmethod
def _check_op(r, name):
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"{name} HTTP {r.status_code}: {r.text[:120]}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "操作失败"))[:120])
return j
+3
View File
@@ -0,0 +1,3 @@
fastmcp>=2.0
httpx>=0.27
Pillow>=10.0