feat(M0): MCP 手机控制 Server 骨架——FastMCP HTTP 传输(8033),平台登录会话+CSRF 封装,de_list_devices/de_screenshot(图像块)/de_tap/de_swipe,写门控/白名单/审计,实测全链路通过
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""MCP 手机控制 Server 包。"""
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fastmcp>=2.0
|
||||
httpx>=0.27
|
||||
Pillow>=10.0
|
||||
Reference in New Issue
Block a user