From 2ba9f0c23915a698bd0f17ed68f0d59a0ce2c1c5 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Fri, 4 Sep 2026 12:42:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(M0):=20MCP=20=E6=89=8B=E6=9C=BA=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=20Server=20=E9=AA=A8=E6=9E=B6=E2=80=94=E2=80=94FastMC?= =?UTF-8?q?P=20HTTP=20=E4=BC=A0=E8=BE=93=EF=BC=888033=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=99=BB=E5=BD=95=E4=BC=9A=E8=AF=9D+CSRF=20?= =?UTF-8?q?=E5=B0=81=E8=A3=85=EF=BC=8Cde=5Flist=5Fdevices/de=5Fscreenshot(?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E5=9D=97)/de=5Ftap/de=5Fswipe=EF=BC=8C?= =?UTF-8?q?=E5=86=99=E9=97=A8=E6=8E=A7/=E7=99=BD=E5=90=8D=E5=8D=95/?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=EF=BC=8C=E5=AE=9E=E6=B5=8B=E5=85=A8=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp_server/__init__.py | 1 + mcp_server/audit.py | 27 +++++++ mcp_server/config.py | 32 ++++++++ mcp_server/mcp_server.py | 146 ++++++++++++++++++++++++++++++++++ mcp_server/platform_client.py | 107 +++++++++++++++++++++++++ mcp_server/requirements.txt | 3 + 6 files changed, 316 insertions(+) create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/audit.py create mode 100644 mcp_server/config.py create mode 100644 mcp_server/mcp_server.py create mode 100644 mcp_server/platform_client.py create mode 100644 mcp_server/requirements.txt diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..c34777c --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1 @@ +"""MCP 手机控制 Server 包。""" diff --git a/mcp_server/audit.py b/mcp_server/audit.py new file mode 100644 index 0000000..b1f8f53 --- /dev/null +++ b/mcp_server/audit.py @@ -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 diff --git a/mcp_server/config.py b/mcp_server/config.py new file mode 100644 index 0000000..2267ca3 --- /dev/null +++ b/mcp_server/config.py @@ -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")) diff --git a/mcp_server/mcp_server.py b/mcp_server/mcp_server.py new file mode 100644 index 0000000..11ffb38 --- /dev/null +++ b/mcp_server/mcp_server.py @@ -0,0 +1,146 @@ +"""MCP 手机控制 Server(M0:设备列表/截图/点击/滑动)。 + +运行:MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server +客户端:Streamable HTTP @ http://: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() diff --git a/mcp_server/platform_client.py b/mcp_server/platform_client.py new file mode 100644 index 0000000..3cf4f0d --- /dev/null +++ b/mcp_server/platform_client.py @@ -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 diff --git a/mcp_server/requirements.txt b/mcp_server/requirements.txt new file mode 100644 index 0000000..4488934 --- /dev/null +++ b/mcp_server/requirements.txt @@ -0,0 +1,3 @@ +fastmcp>=2.0 +httpx>=0.27 +Pillow>=10.0