从零构建用于 Android 开发的 MCP 服务:原理、实践与工程思考

在 AI 浪潮席卷软件开发的今天,我们 Android 工程师的工具箱也迎来了新的可能。除了传统的 Android Studio,各类 AI 助手和 Agent 正逐渐成为我们日常编码、调试、测试的得力伙伴。然而,要让这些"聪明的同事"真正理解并操作我们的 Android 应用,我们需要一座桥梁------这便是 MCP (Model-Controlled Procedures) 服务器。

本文将带你从零开始,使用 Python 构建一个专为 Android 开发设计的简易 MCP 服务器。我们将不仅探讨"如何做",更会深入"为何如此做",并分享在实践中总结的最佳实践与安全考量。

背景与动机:为何要为 Android 开发自建 MCP 服务器?

当我们与 AI Agent 协作时,无论是修复一个 UI bug,还是验证一个新功能,都离不开频繁的反馈循环。我们可能需要反复向 Agent 描述"按钮在哪里"、"点击后界面变成了什么样"、"Logcat 打印了什么错误"。这个过程是繁琐且低效的。

MCP 服务器通过为 AI Agent 提供一套可调用的"工具集"(Tools),允许它自主地与外部环境交互,从而打破这种僵局。对于 Android 开发而言,这意味着 AI Agent 可以:

  • 自主观察:主动获取屏幕截图、UI 布局、设备日志。
  • 自主操作:模拟点击、滑动、输入文本,甚至执行返回、Home 等系统操作。

那么,为何不直接使用网上现成的 MCP 服务呢?

  1. 安全与可控性:将一个能完全控制你开发设备的"后门"交给一个未知的第三方服务,无异于将堡垒的钥匙拱手让人。潜在的安全风险和对开发环境完整性的威胁是任何专业团队都无法忽视的。自建服务器,意味着所有能力和操作都在你的掌控之中。
  2. 定制与整合能力:每个团队都有自己独特的工具链和工作流。自建 MCP 服务器可以无缝集成你已有的内部工具(如自动化测试框架、特定 adb 脚本),为 AI Agent 提供最贴合业务场景的超能力。
  3. 遵循合规策略:在企业环境中,任何与外部服务的通信都可能受到严格的安全策略限制。一个本地运行、代码透明的 MCP 服务器能确保所有操作均在合规的框架内进行。

自己动手,丰衣足食。通过构建自己的 MCP 服务器,我们不仅能收获一个强大、安全的 AI 协作工具,更能深入理解 AI Agent 的工作范式,为将来探索更高级的自动化流程打下坚实基础。

MCP 概念速览:它究竟是什么?

在我们动手之前,花几分钟快速理解 MCP 的几个核心概念至关重要。

  • Server/Client 模型 :MCP 的核心是一个 C/S 架构。Server 是我们即将构建的服务,它定义并实现了一系列工具(如 get_screenshot)。Client 通常是 AI Agent 的一部分,它会发现 Server 提供的工具,并在需要时发起调用。这种解耦设计让能力可以被灵活替换和组合。

  • 工具 (Tools):工具是 MCP Server 能力的原子化体现。每个工具都是一个函数,拥有明确的名称、描述、输入参数和输出。AI Agent 正是根据这些元信息来理解并决定何时、如何使用某个工具。

  • 传输模式 (Transport Modes):Client 与 Server 之间需要通信。MCP 规范定义了多种传输协议,以适应不同场景:

    • stdio:通过标准输入/输出进行通信。这是最简单的方式,非常适合本地进程间通信,例如在 MCP Inspector 中进行快速调试。
    • streamable-http / sse (Server-Sent Events):基于 HTTP。当 Client 和 Server 运行在不同机器上,或者需要通过网络进行更复杂的交互时,会采用这些模式。

理解了这些,我们就可以开始规划我们的技术选型了。

技术选型与环境准备

尽管我们是 Android 开发者,更熟悉 Kotlin/Java,但在当前生态下,Python 是构建 MCP 服务器的更成熟选择。

  • 核心框架 : Python MCP SDK (mcp[cli])。它提供了 FastMCP 等高层抽象,让我们能专注于工具逻辑而非底层协议实现。
  • 图像处理 : Pillow。当我们需要处理截图(例如,为了减少传输给模型的图片尺寸)时,Pillow 是一个强大且易用的图像处理库。
  • Python 环境管理 : uv。一个新兴、极速的 Python 包管理器,可以帮我们创建虚拟环境并管理依赖。当然,你也可以使用 venv + pip
  • 安卓交互核心 : adb (Android Debug Bridge)。我们所有与 Android 设备交互的工具,其底层都将通过调用 adb 命令来实现。请确保它已正确安装并加入了系统 PATH。
  • 调试与测试 : MCP Inspector。一个官方提供的小工具,可以让我们在没有 AI Agent 的情况下,直观地测试我们编写的 MCP Server,查看工具定义、发送调用请求并验证返回结果。它通过 npx 运行,需要 Node.js 环境。

我们的目标工具集

为了构建一个功能虽简但五脏俱全的服务器,我们将实现以下工具:

  • get_logcat_output:使用 logcat 获取设备日志。
  • get_screenshot:捕获当前屏幕内容。
  • get_ui_dump:获取 XML 格式的 UI 视图层级。
  • tap_screen:在屏幕指定坐标执行点击。
  • swipe_screen:执行滑动操作。
  • send_text:模拟键盘输入文本。
  • perform_system_action:执行返回、Home 等系统级操作。

现在,环境就绪,蓝图已定,让我们卷起袖子开始编码!

核心能力设计与实践路径

我们将遵循"项目初始化 → 依赖配置 → 启动参数设计 → 工具实现 → 整合启动"的清晰路径。

步骤一:项目初始化与依赖配置

首先,打开你的终端,创建一个新项目。

bash 复制代码
# 使用 uv 初始化项目,它会自动创建一个名为 android-mcp-server 的目录和虚拟环境
uv init android-mcp-server
cd android-mcp-server

接着,编辑项目根目录下的 pyproject.toml 文件,声明我们的项目信息和依赖。

toml 复制代码
[project]
name = "android-dev-mcp-server"
version = "1.0.0"
description = "An MCP Server for Android Development"
readme = "README.md"
requires-python = ">=3.10" # 建议使用较新的 Python 版本
dependencies = [
  "mcp[cli]==1.22.0", # 请根据需要锁定或更新版本
  "Pillow==10.3.0",
]

配置完成后,回到终端,使用 uv 同步依赖。

bash 复制代码
uv sync

步骤二:启动参数设计

一个健壮的命令行工具需要灵活的启动参数。在项目根目录创建 main.py,我们先来实现参数解析部分。

python 复制代码
# main.py
import argparse

def parse_args() -> tuple[str, str, int]:
    parser = argparse.ArgumentParser(description="Android Development MCP Server")
    
    parser.add_argument(
        "--mode",
        dest="mode",
        type=str,
        choices=["stdio", "streamable-http", "sse"],
        required=True,
        help="The mode to run the MCP server in.",
    )
    parser.add_argument(
        "--temp-dir",
        dest="temp_dir",
        type=str,
        required=True,
        help="Absolute path to a temporary directory for the MCP server.",
    )
    parser.add_argument(
        "--port",
        dest="port",
        type=int,
        default=3001,
        help="The port to run the MCP server on for HTTP-based modes.",
    )
    
    args = parser.parse_args()
    return args.mode, args.temp_dir, args.port

这段代码定义了三个关键参数:--mode 用于选择传输模式,--temp-dir 用于存放截图等临时文件,--port 则为 HTTP 模式指定端口。

步骤三:核心工具实现要点

现在是核心部分。我们将在 main.py 中继续添加代码,实现所有工具。所有工具都将定义在 start_server 函数内部,以便访问 mcp 实例和共享的 temp_dir

首先,引入所有必要的模块,并创建一个 adb 辅助函数。

python 复制代码
# main.py (继续添加)
import io
import os
import subprocess
import shlex
import xml.etree.ElementTree as ET

from mcp.server.fastmcp import FastMCP, Image
from mcp.server.fastmcp.exceptions import ToolError
from PIL import Image as PILImage
from pydantic import Field

# ... parse_args() 函数 ...

def call_adb_silent(args: list[str]):
    """一个静默执行 adb 命令的辅助函数,不关心其输出。"""
    subprocess.run(["adb"] + args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def start_server(mode: str, temp_dir: str, port: int):
    # 确保临时目录存在
    os.makedirs(temp_dir, exist_ok=True)

    mcp = FastMCP(
        name="Android Development MCP Server",
        port=port,
    )

    # --- 在这里定义所有工具 ---

1. get_logcat_output:读取日志

此工具通过 adb logcat 获取日志,并增加了按包名和日志级别过滤的功能。

python 复制代码
    @mcp.tool(structured_output=True)
    def get_logcat_output(
        app_package: str = Field(description="The base package of the app to get the logs from."),
        log_level: str = Field(description="The log level to filter (DEBUG, WARNING, ERROR).", default="DEBUG")
    ) -> str:
        """Retrieves the last 100 lines of logs from the connected Android device."""
        # ... (此处省略了原文中通过 pidof 查找进程的逻辑,简化为按 tag 过滤)
        # 在实际工程中,结合 pid 过滤会更精确
        log_level_map = {"DEBUG": "D", "WARNING": "W", "ERROR": "E"}
        if log_level.upper() not in log_level_map:
            raise ToolError(f"Invalid log level: {log_level}.")
        
        try:
            # -d 表示 dump a log and exit
            result = subprocess.run(
                ["adb", "logcat", "-d", "-t", "100", f"*:{log_level_map[log_level.upper()]}"],
                capture_output=True, text=True, check=True
            )
            # 简单的按包名过滤
            filtered_lines = [line for line in result.stdout.splitlines() if app_package in line]
            return "\n".join(filtered_lines)
        except subprocess.CalledProcessError as e:
            raise ToolError(f"Error getting logcat output: {e.stderr}")

工程提示@mcp.tool() 装饰器将一个 Python 函数注册为 MCP 工具。函数文档字符串(docstring)会成为给 AI Agent 的工具描述。pydantic.Field 用于为参数提供详细描述和默认值。当执行出错时,务必抛出 ToolError,它会将错误信息以友好的格式返回给 Agent。

2. get_screenshot:捕获屏幕

该工具使用 adb shell screencap 截图,并用 Pillow 库缩放图片,以减少传给模型的流量和计算负担。

python 复制代码
    @mcp.tool()
    def get_screenshot() -> Image:
        """
        Gets a screenshot of the connected Android device.
        Use this to check the visual appearance. Prefer get_ui_dump for element identification.
        """
        try:
            screenshot_path = os.path.join(temp_dir, "screenshot.png")
            # 截图并保存到设备
            call_adb_silent(["shell", "screencap", "-p", "/sdcard/screenshot.png"])
            # 从设备拉取到本地
            call_adb_silent(["pull", "/sdcard/screenshot.png", screenshot_path])
            # 删除设备上的临时文件
            call_adb_silent(["shell", "rm", "/sdcard/screenshot.png"])

            # 使用 Pillow 缩放图片
            with PILImage.open(screenshot_path) as img:
                scale_factor = 0.5 # 缩放比例可配置
                new_width = int(img.width * scale_factor)
                new_height = int(img.height * scale_factor)
                resized_img = img.resize((new_width, new_height))
                
                buffered = io.BytesIO()
                resized_img.save(buffered, format="PNG")
                img_bytes = buffered.getvalue()

            os.remove(screenshot_path) # 清理本地临时文件
            return Image(data=img_bytes, format="png")
        except Exception as e:
            raise ToolError(f"Error getting screenshot: {e}")

3. get_ui_dump:获取 UI 布局

这是 AI Agent 理解屏幕结构的关键。它调用 uiautomator dump 获取 XML,并允许 Agent 指定只返回感兴趣的节点属性,以精简信息。

python 复制代码
    @mcp.tool(structured_output=True)
    def get_ui_dump(
        returned_attributes: str = Field(description="Comma-separated attributes to return, e.g., 'bounds,class,text,clickable'.")
    ) -> str:
        """Gets the UI hierarchy dump as an XML string."""
        if not returned_attributes:
            raise ToolError("The 'returned_attributes' argument cannot be empty.")
        
        attributes_to_keep = {attr.strip() for attr in returned_attributes.split(',')}
        
        try:
            dump_path = os.path.join(temp_dir, "window_dump.xml")
            call_adb_silent(["shell", "uiautomator", "dump"])
            call_adb_silent(["pull", "/sdcard/window_dump.xml", dump_path])
            call_adb_silent(["shell", "rm", "/sdcard/window_dump.xml"])

            with open(dump_path, "r", encoding="utf-8") as f:
                ui_dump = f.read()
            os.remove(dump_path)

            # 解析 XML 并移除不需要的属性
            root = ET.fromstring(ui_dump)
            for node in root.iter():
                unwanted_attrs = [attr for attr in node.attrib if attr not in attributes_to_keep]
                for attr in unwanted_attrs:
                    del node.attrib[attr]
            
            return ET.tostring(root, encoding="unicode")
        except Exception as e:
            raise ToolError(f"Error getting UI dump: {e}")

4. tap_screen, swipe_screen, send_text, perform_system_action

这几个操作类工具的实现相对直接,都是对 adb shell input 命令的封装。

python 复制代码
    @mcp.tool(structured_output=True)
    def tap_screen(x: int = Field(description="x-coordinate"), y: int = Field(description="y-coordinate")) -> str:
        """Taps on the screen at the given coordinates."""
        try:
            call_adb_silent(["shell", "input", "tap", str(x), str(y)])
            return f"Tapped at ({x}, {y})."
        except Exception as e:
            raise ToolError(f"Error tapping on screen: {e}")

    @mcp.tool(structured_output=True)
    def swipe_screen(x1: int, y1: int, x2: int, y2: int) -> str:
        """Swipes on the screen from a starting point to an ending point."""
        try:
            call_adb_silent(["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2)])
            return f"Swiped from ({x1}, {y1}) to ({x2}, {y2})."
        except Exception as e:
            raise ToolError(f"Error swiping on screen: {e}")

    @mcp.tool(structured_output=True)
    def send_text(text_to_send: str = Field(description="The text to send.")) -> str:
        """Sends the given text, as if typed on a keyboard."""
        if not text_to_send:
            raise ToolError("Text cannot be empty.")
        try:
            # 使用 shlex.quote 来正确处理特殊字符和空格
            escaped_text = shlex.quote(text_to_send)
            call_adb_silent(["shell", "input", "text", escaped_text])
            return f"Sent text: {text_to_send}"
        except Exception as e:
            raise ToolError(f"Error sending text: {e}")

    @mcp.tool(structured_output=True)
    def perform_system_action(action: str = Field(description="System action: BACK, HOME, or RECENT_APPS.")) -> str:
        """Performs a system action like back, home, or recent apps."""
        action_map = {"BACK": "KEYCODE_BACK", "HOME": "KEYCODE_HOME", "RECENT_APPS": "KEYCODE_APP_SWITCH"}
        if action.upper() not in action_map:
            raise ToolError(f"Invalid action: {action}. Possible actions: BACK, HOME, RECENT_APPS.")
        
        try:
            call_adb_silent(["shell", "input", "keyevent", action_map[action.upper()]])
            return f"Performed action: {action}."
        except Exception as e:
            raise ToolError(f"Error performing system action: {e}")

工程提示 :对于 send_text,使用 shlex.quote 至关重要,它可以防止包含空格或特殊符号的文本在 shell 中被错误解析。

步骤四:整合与启动

万事俱备,只欠东风。在 start_server 函数的末尾,我们根据 --mode 参数来启动服务器。并在 main.py 的最后,加上标准的 Python 脚本入口。

python 复制代码
# main.py (在 start_server 函数末尾添加)

    # --- 所有工具定义结束 ---

    # 根据模式启动服务器
    print(f"Starting Android MCP Server in '{mode}' mode...")
    if mode == "stdio":
        mcp.run(transport="stdio")
    elif mode == "streamable-http":
        print(f"Running on http://localhost:{port}/mcp")
        mcp.run(transport="streamable-http")
    elif mode == "sse":
        print(f"Running on http://localhost:{port}/sse")
        mcp.run(transport="sse")
    else:
        # 理论上 argparse 会处理,但作为兜底
        print(f"Unsupported mode: {mode}")

# 在文件末尾添加主入口
if __name__ == "__main__":
    mode_arg, temp_dir_arg, port_arg = parse_args()
    start_server(mode=mode_arg, temp_dir=temp_dir_arg, port=port_arg)

至此,我们的 main.py 已经是一个功能完整的 MCP 服务器了!

实战演练:在 MCP Inspector 中测试

代码写完,必须测试。mcp-inspector 是我们最好的朋友。

首先,在项目根目录创建一个 mcp-inspector-config.json 配置文件,方便我们快速启动。

json 复制代码
{
  "mcpServers": {
    "android-stdio": {
      "command": "uv",
      "args": [
        "run",
        "main.py",
        "--mode",
        "stdio",
        "--temp-dir",
        "/tmp/android_mcp" // Linux/macOS. Windows 用户请改为 "C:/Temp/android_mcp" 之类的有效路径
      ]
    },
    "android-http": {
      "type": "streamable-http",
      "url": "http://127.0.0.1:3001/mcp"
    }
  }
}

注意 :请确保 command 在你的系统 PATH 中可用,且 temp-dir 指向一个已存在的、可写的目录。

现在,在终端中启动 Inspector,并连接到我们的 stdio 服务。

bash 复制代码
npx @modelcontextprotocol/inspector@latest --config mcp-inspector-config.json --server android-stdio

如果一切顺利,一个 Web 界面会自动打开。你将在左侧看到我们定义的所有工具。点击任意一个,比如 get_ui_dump,在右侧填入参数(如 bounds,text,clickable,resource-id),然后点击 "Run"。稍等片刻,你应该就能在下方看到从你连接的 Android 设备上获取到的、经过处理的 UI XML 数据。

逐个尝试我们实现的所有工具,确保它们都按预期工作。

工程最佳实践与安全考量

构建一个能工作的服务器只是第一步,构建一个健壮、安全的服务器才是工程落地的关键。

  • 能力最小化原则 :仅暴露完成任务所必需的工具。避免提供像 execute_shell_command 这样过于宽泛和危险的工具。我们的设计已经遵循了这一原则。
  • ADB 安全adb 是一个强大的后门。确保你的 adb server 没有暴露在不安全的网络中(例如,不要轻易使用 adb tcpip 并连接到公共 Wi-Fi)。我们的 MCP 服务器应在可信的开发环境中运行。
  • 清晰的错误处理 :当 adb 命令失败(如设备未连接、权限不足)时,我们的工具应该捕获 subprocess.CalledProcessError 异常,并将其转换为信息明确的 ToolError。这能帮助 AI Agent 理解失败原因并尝试修复或调整策略。
  • 资源清理 :对于 get_screenshotget_ui_dump 这样会产生临时文件的工具,务必在操作完成后(无论成功还是失败)清理这些文件。使用 try...finally 结构或 Python 的 with 语句是保证清理逻辑被执行的好方法。

为什么 MCP 服务器可以访问手机?------本地进程 + adb 授权通道

从权限和通道的角度,可以把这件事拆成几个工程点来看:

  • MCP 是协议与工具封装:MCP 只定义了 Agent ↔ MCP Server 之间的协议(工具描述、调用与返回),服务器本质上是跑在你电脑上的一个进程。它能做什么,完全取决于你在服务器里实现了哪些工具,而不是"服务器天生拥有访问手机的特权"。
  • 访问能力的根源在 adb 授权:只有当 Android 手机上打开了开发者选项、启用了 USB 调试(或网络 adb),并且你在手机上选择了"信任这台电脑",本机上的 adb 才能被授权访问这台设备。此后,任何会调用 adb 的本地程序------包括你的 MCP 服务器------都可以通过这条调试通道与手机交互。
  • 交互通道与数据流 :MCP 服务器内部通过 adb 下发各种操作命令,例如 input tapinput swipeinput text 以及系统按键等;手机侧则通过 adb 回传 logcat 日志、截图文件、UI Dump XML 等原始数据。服务器再把这些结果整理成结构化输出,返回给 Agent 使用。
  • 本地与网络模式的权衡 :为了降低攻击面,更推荐将 MCP 服务器以本地进程或 http://127.0.0.1 的 HTTP 模式运行,让 Agent 通过本机回环地址访问。如果确实需要跨机器部署(例如挂在一台专门的真机机房服务器上),务必加上鉴权(Token/MTLS 等)、网络隔离(VPC/防火墙策略)和操作白名单,只开放必要的少量工具。
  • 安全边界与最小能力 :从安全边界看,真正敏感的是 adb 通道本身。工程上应尽量限制 MCP 工具的范围和粒度,做好参数校验与临时文件清理,不要在不可信网络上暴露 adb 服务端,更不要随意开启并裸露 adb tcpip 端口;如果必须启用,需配合内网隔离和访问控制策略使用。

你可以用一段极简的命令行来验证:所有"远程控制手机"的能力最终都来自 adb,而 MCP 只是把这些能力以工具形式暴露给 Agent:

bash 复制代码
# 验证设备授权
adb devices
# 执行一次点击(演示能力来源于 adb)
adb shell input tap 100 200

扩展与展望

我们构建的只是一个基础版本。基于这个框架,你可以轻松地进行扩展:

  • 企业级增强 :如果需要接入公司内部的自动化测试框架或设备管理平台,你可以编写新的工具来调用它们的 API,取代直接调用 adb。同时,可以为 HTTP 模式的服务器增加鉴权中间件。
  • 与 CI/CD 集成:将此 MCP 服务器作为 CI 流水线的一部分。例如,在 E2E 测试失败后,自动启动服务器,让 AI Agent 介入,分析失败时的截图、日志和 UI 结构,甚至尝试定位问题。
  • 组合工具与更高层抽象 :目前我们的工具都是原子操作。你可以创建更高层的组合工具,如 open_app_and_login(appName, username, password),它内部会依次调用 tap_screen, send_text 等基础工具来完成一个完整的业务流程。

常见问题(FAQ)与排查指南

  • uiautomator dump 在不同设备或系统版本上输出的 XML 可能有细微差异。这是正常的。在提示(Prompt)中引导 AI Agent 编写具有一定鲁棒性的解析逻辑是关键。
  • input text 无法输入特殊字符或中文? 确保你使用了 shlex.quote 进行转义。对于复杂的文本输入,有时需要 adb-unicode-py-client 这样的第三方库。
  • 坐标定位不准? 不同设备的分辨率和密度不同,硬编码坐标是不可靠的。正确的做法是,让 Agent 从 get_ui_dumpbounds 属性中动态计算出目标的中心点坐标,然后再调用 tap_screen
  • 截图或 UI Dump 太慢? screencapuiautomator dump 本身有一定耗时。可以通过优化截图尺寸和压缩率(Pillow)、减少 get_ui_dump 请求的属性数量来提升性能。

总结与下一步

恭喜你!至此,你已经完整地走过了一个从零构建 Android MCP 服务器的全过程。我们不仅理解了其背后的原理,掌握了核心的技术栈,还亲手实现了一个功能齐备的工具集,并探讨了将其投入实际生产所需考虑的工程问题。

这不仅仅是完成了一个玩具项目,更是为你打开了一扇通往 AI 驱动的 Android 开发新世界的大门。

相关推荐
檐下翻书1732 小时前
企业组织架构图导出Word 在线编辑免费工具
人工智能·信息可视化·去中心化·word·流程图·ai编程
Billy_Zuo2 小时前
Android Studio 打aar包
android·ide·android studio
XiaoLeisj2 小时前
Android UI 布局与容器实战:LinearLayout、RelativeLayout、ConstraintLayout
android·ui
FE_C_P小麦2 小时前
别再被割!OpenClaw小龙虾根本带不动普通人赚钱,再瞎玩月亏上万都是常态
ai编程
花间相见2 小时前
【AI开发】—— 山东省智能政策助手部署实战:从 0 到 1 上线与更新避坑指南
人工智能·copilot·ai编程
summerkissyou19872 小时前
Android-Audio-编码和解码
android·audio
dawudayudaxue2 小时前
Eclipse安卓环境配置
android·java·eclipse
曾经我也有梦想2 小时前
Day5 Kotlin 协程
android
zhensherlock2 小时前
Protocol Launcher 系列:一键唤起 Windsurf 智能 IDE
javascript·ide·vscode·ai·typescript·github·ai编程