1. 为什么 Android 开发需要一个本地 MCP 服务
如果你正在做 Android 开发,大概率已经习惯了这样的循环:改一版代码,装到真机上,手动点几下,看 Logcat 有没有报错,再截个图对比 UI。这套动作本身不复杂,但重复几十次之后,时间就被切得很碎。AI Agent 能帮忙写代码、改 bug,可它看不到你的屏幕、读不到你的日志,每次都得你手动把信息喂给它,效率提升有限。
MCP(Model-Controlled Procedures)服务解决的就是这个断层。它本质上是跑在你本机的一个进程,把 adb 的能力封装成一组标准化的工具,让 AI Agent 可以自主调用。Agent 想知道当前界面长什么样,就调get_ui_dump;想确认按钮点没点中,就调get_screenshot;想复现一个崩溃,就调get_logcat_output。整个过程不需要你反复复制粘贴。
这篇文章面向的是有 Android 开发基础、想自己搭一套 MCP 服务把 adb 工具链接进来的工程师。我会从零开始,用 Python 写一个最小可运行的 MCP 服务端,把 adb 的常用操作注册成工具,再通过 TaoToken 统一 Key 接入模型能力,最后用 MCP Inspector 验证整条链路。全程可复制,配置和命令都给出,踩过的坑也会标出来。
适合谁:手上有真机或模拟器、装了 adb、想用 AI Agent 辅助调试的 Android 开发者。不需要你精通 Python,但至少要能看懂函数和命令行参数。
2. TaoToken 前置:统一 Key 与接入准备
在写 MCP 服务之前,先把模型接入这块理清楚。MCP 服务本身只负责暴露工具,真正做决策的是背后的 AI Agent。Agent 要调用模型,就需要一个稳定的 API 入口。TaoToken 在这里的角色是统一 Key 管理,你不需要为每个模型单独申请一套凭证,一个 Key 就能覆盖对话、编码、Agent 等场景。
先到官网注册并拿到 API Key:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
拿到 Key 之后,进入控制台创建或查看已有的 Key:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console
API 的基础地址是https://taotoken.net/api,这个地址在后续配置 MCP 服务的模型调用时会用到。注意,API 地址不带 UTM 参数,直接写就行。
如果你打算长期用 AI Agent 做编码和调试,建议看一下 Coding Plan,它更适合高频调用的场景:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan
Key 的管理页面在这里,可以随时查看用量和重置:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys
接入文档在:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc
模型对话的调试入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model-chat
Claude Code 相关的接入说明:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude-code
这些链接里,官网和 API 地址是必须的,其余按需取用。Key 拿到后先放一边,后面配置 MCP 服务时会用到。
3. 可复制配置:MCP 服务骨架与 adb 工具注册
3.1 项目初始化与依赖
我用uv来管理 Python 环境,它比 pip 快很多,虚拟环境创建也干净。如果你习惯 venv + pip,把命令替换掉即可。
uv init android-mcp-server cd android-mcp-server编辑pyproject.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" dependencies = [ "mcp[cli]==1.22.0", "Pillow==10.3.0", ]同步依赖:
uv sync确保 adb 已经安装并加入 PATH,执行adb devices能看到设备列表。如果这一步报错,先解决 adb 环境问题,MCP 服务本身不负责安装 adb。
3.2 启动参数设计
在项目根目录创建main.py,先写参数解析部分。三个关键参数:--mode选择传输模式,--temp-dir指定临时文件目录,--port用于 HTTP 模式。
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.portstdio模式适合本地调试,Agent 和 MCP 服务在同一台机器上通过标准输入输出通信。streamable-http和sse适合跨机器部署,但生产环境一定要加鉴权。
3.3 adb 辅助函数与工具注册
继续在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 def call_adb_silent(args: list[str]): """静默执行 adb 命令,不关心输出。""" subprocess.run( ["adb"] + args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )然后是start_server函数,所有工具都定义在里面:
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, )接下来逐个注册工具。先看日志获取:
@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.""" 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: 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}")截图工具,用 Pillow 缩放减少传输量:
@mcp.tool() def get_screenshot() -> Image: """Gets a screenshot of the connected Android device.""" 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"]) 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}")UI 层级 dump,允许 Agent 指定只返回关心的属性:
@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) 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}")操作类工具,点击、滑动、输入、系统按键:
@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: 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}")最后是启动逻辑和入口:
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: 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)到这里,一个包含 7 个工具的 MCP 服务就写完了。每个工具的 docstring 会成为 Agent 看到的描述,Field里的 description 是参数说明,这两处写清楚,Agent 才知道什么时候该调哪个工具。
4. 验证请求:MCP Inspector 联通与 adb 调用实测
代码写完必须验证。MCP Inspector 是官方提供的调试工具,可以在没有 Agent 的情况下直接测试工具。
在项目根目录创建mcp-inspector-config.json:
{ "mcpServers": { "android-stdio": { "command": "uv", "args": [ "run", "main.py", "--mode", "stdio", "--temp-dir", "/tmp/android_mcp" ] }, "android-http": { "type": "streamable-http", "url": "http://127.0.0.1:3001/mcp" } } }Windows 用户把--temp-dir改成C:/Temp/android_mcp这类有效路径。确保uv在 PATH 中。
启动 Inspector:
npx @modelcontextprotocol/inspector@latest --config mcp-inspector-config.json --server android-stdio浏览器会自动打开一个界面,左侧列出所有注册的工具。先测get_ui_dump,参数填bounds,text,clickable,resource-id,点 Run。如果设备连接正常,下方会返回处理过的 XML 数据,只保留了你指定的属性。
再测get_screenshot,返回的是一张 PNG 图片,尺寸是原屏幕的一半。如果图片能正常显示,说明 adb 截图、拉取、缩放、清理这条链路是通的。
测tap_screen,填一个坐标比如540,1200,观察设备屏幕是否有反应。如果没反应,先确认adb devices能看到设备,再确认坐标在屏幕范围内。
get_logcat_output测的时候,app_package填你的应用包名,log_level填ERROR,看是否能过滤出错误日志。如果返回空,可能是最近 100 行里没有该包名的日志,换个级别或先手动触发一条日志再试。
所有工具都验证通过后,把 MCP 服务接入你的 AI Agent。以 stdio 模式为例,Agent 侧的配置大致如下:
{ "mcpServers": { "android-dev": { "command": "uv", "args": [ "run", "main.py", "--mode", "stdio", "--temp-dir", "/tmp/android_mcp" ] } } }Agent 启动时会自动发现这 7 个工具,并根据任务需要调用。模型侧的 API 地址填https://taotoken.net/api,Key 用你在 TaoToken 控制台创建的那个。
5. 本篇常见错排查
5.1 adb devices 看不到设备
这是最常见的问题。先检查 USB 线是否支持数据传输,有些线只能充电。然后在手机上确认开发者选项和 USB 调试已开启,并且弹出了“信任这台电脑”的授权框。如果之前拒绝过,到开发者选项里撤销 USB 调试授权,重新插拔。
网络 adb 的情况,确保手机和电脑在同一网段,且没有开启 AP 隔离。adb connect的地址格式是IP:端口,端口默认 5555,但不同设备可能不同。
5.2 uiautomator dump 返回空或报错
部分设备或系统版本对uiautomator dump的支持有差异。如果返回的 XML 为空,先手动执行adb shell uiautomator dump看设备端是否正常生成文件。如果设备端正常但拉取失败,检查/sdcard/window_dump.xml的权限。
另外,uiautomator dump在某些界面(如视频播放、游戏)可能拿不到完整层级,这是系统限制,不是 MCP 服务的问题。
5.3 input text 无法输入中文或特殊字符
adb shell input text对中文和部分特殊字符支持有限。shlex.quote能处理空格和 shell 特殊符号,但中文需要额外方案。简单场景可以用 ADBKeyboard 这类输入法替代,复杂场景建议在 MCP 工具里集成第三方库。
5.4 截图或 UI Dump 太慢
screencap和uiautomator dump本身有耗时,尤其在低端设备上。优化方向:截图缩放比例调小,比如从 0.5 降到 0.3;get_ui_dump只请求必要的属性,减少 XML 体积;如果 Agent 不需要每次都看全量 UI,可以在 prompt 里引导它优先用get_ui_dump定位元素,只在必要时才调get_screenshot。
5.5 MCP Inspector 连不上 stdio 服务
检查mcp-inspector-config.json里的command是否在 PATH 中。如果用的是uv,确认uv已安装且版本支持uv run。--temp-dir指向的目录必须存在且可写,Windows 下路径分隔符用正斜杠或双反斜杠。
如果 Inspector 启动后界面空白,看终端有没有报错。常见的是 Python 依赖没同步,执行uv sync后重试。
5.6 工具调用返回 ToolError 但信息不明确
ToolError会把错误信息返回给 Agent,所以错误描述要写清楚。比如 adb 命令失败时,把e.stderr带上,Agent 才能知道是设备未连接还是权限不足。不要用裸的except Exception吞掉错误,至少把异常类型和消息拼进去。
6. 接入文档与后续扩展
MCP 服务跑通之后,下一步可以按需扩展。如果你主要用 Agent 做日常编码和调试,Coding Plan 的调用额度更适合高频场景:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan
需要查看或重置 Key,到 API Keys 页面:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys
接入过程中遇到协议或参数问题,查接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc
想先手动验证模型对话是否正常,用模型对话入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model-chat
Claude Code 相关的配置参考:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude-code
扩展方向有几个:把常用的 adb 脚本封装成组合工具,比如“打开应用并登录”;给 HTTP 模式加 Token 鉴权中间件;把 MCP 服务接入 CI 流水线,在 E2E 测试失败时自动让 Agent 分析截图和日志。这些都可以在当前骨架上叠加,不需要重写。
最后提醒一句:adb 通道本身权限很大,MCP 服务只应在可信的开发环境运行。不要在没有鉴权和网络隔离的情况下把 HTTP 模式暴露到公网。