mobile-bridge-mcp,实现AI远程操控手机上的web页面

29 阅读5分钟

Whistle 远程控制桥接 MCP Server

概述

通过 whistle 代理向手机页面注入 JS 脚本,让电脑端编码助手(Qoder IDE)能够远程操控手机上的 Web 页面。提供 take_snapshot、click、fill、scroll、screenshot 等工具,类似 chrome-devtools-mcp 操控桌面浏览器的体验。

项目路径/Users/alsc/Documents/mobile-bridge-mcp/


系统架构图

graph TB
    subgraph 电脑端
        Q[编码助手 Qoder]
        MCP[MCP Server<br/>stdio 模式]
        BS[Bridge Server<br/>HTTP + WebSocket :9300]
        W[Whistle 代理<br/>:8899]

        Q -->|调用 MCP 工具| MCP
        MCP -->|内嵌启动| BS
    end

    subgraph 手机端
        BR[手机浏览器]
        JS[bridge-client.js<br/>注入的脚本]
        DOM[页面 DOM]

        BR -->|执行| JS
        JS -->|操作| DOM
    end

    BR -->|WiFi 代理| W
    W -->|jsAppend 注入脚本| BR
    JS -->|HTTPS: 同源路径长轮询| W
    W -->|路径匹配 /__mb__/ 转发| BS
    JS -->|HTTP: WebSocket 直连| BS

核心设计:双传输模式

问题背景

手机通过 whistle 代理访问 HTTPS 页面时,存在以下限制:

  • Mixed Content:HTTPS 页面无法加载 http:// 资源或建立 ws:// 连接
  • 假域名不可达mobile-bridge.local 等假域名在 iOS 上会触发 mDNS 解析,绕过 HTTP 代理
  • WebSocket 不走代理:手机浏览器的 WebSocket 连接可能不经过 HTTP 代理

最终方案:同源路径 + Whistle 路径转发

HTTPS 页面请求流程:
手机脚本 → fetch('https://当前域名/__mb__/register') → whistle 代理拦截
→ 匹配路径规则 /__mb__/ → 转发到 127.0.0.1:9300 → bridge-server 处理

关键原理

  1. 脚本向页面自身域名发起请求(location.origin + '/__mb__/...'),属于同源请求
  2. 同源 HTTPS 请求一定经过 whistle 代理(页面本身就是通过代理加载的)
  3. Whistle 已对该域名做 HTTPS 拦截(jsAppend 需要),所以能看到请求路径
  4. Whistle 按路径 /__mb__/ 匹配规则,将请求转发到本机 bridge-server(HTTP :9300)
  5. 不存在 Mixed Content 问题,不需要假域名,不需要 DNS 解析

交互时序图

sequenceDiagram
    participant U as 编码助手 Qoder
    participant M as MCP Server
    participant BS as Bridge Server :9300
    participant W as Whistle 代理
    participant B as 手机浏览器
    participant JS as bridge-client.js

    Note over U,JS: === 初始化阶段 ===
    U->>M: IDE 自动启动 MCP Server (stdio)
    M->>BS: 内嵌启动 Bridge Server (:9300)

    B->>W: 请求 HTTPS 页面
    W->>W: jsAppend 注入 bridge-client.js
    W->>B: 返回页面 + 注入脚本
    B->>JS: 执行注入脚本

    Note over JS,BS: === HTTPS 长轮询注册 ===
    JS->>W: POST https://当前域名/__mb__/register
    W->>BS: 路径匹配,转发到 127.0.0.1:9300
    BS->>W: 200 {clientId: "abc123"}
    W->>JS: 注册成功

    Note over JS,BS: === 长轮询循环 ===
    JS->>W: GET https://当前域名/__mb__/poll?clientId=abc123
    W->>BS: 转发
    BS-->>BS: 挂起等待命令 (最长25秒)
    BS->>W: 204 No Content (无命令)
    W->>JS: 204
    JS->>W: 再次 poll...

    Note over U,JS: === 操作阶段 (take_snapshot) ===
    U->>M: 调用 take_snapshot
    M->>BS: sendCommand("snapshot")
    Note over BS: 下次 poll 到来时返回命令
    BS->>W: 200 {msgId, action:"snapshot"}
    W->>JS: 收到命令
    JS->>JS: 遍历 DOM,生成快照
    JS->>W: POST /__mb__/response {msgId, data: 快照}
    W->>BS: 转发结果
    BS->>M: 返回快照
    M->>U: DOM 快照文本

MCP 工具一览

graph TB
    MCP[mobile-bridge MCP Server]
    MCP --> T1[list_clients<br/>列出已连接的手机]
    MCP --> T2[take_snapshot<br/>获取 DOM 文本快照]
    MCP --> T3[click<br/>点击元素]
    MCP --> T4[fill<br/>输入文本]
    MCP --> T5[scroll<br/>滚动页面]
    MCP --> T6[evaluate_script<br/>执行任意 JS]
    MCP --> T7[get_url<br/>获取当前 URL]
    MCP --> T8[screenshot<br/>截图 base64]
    MCP --> T9[get_text<br/>获取元素文本]
工具名参数说明
list_clients列出当前连接的手机设备
take_snapshotclientId?获取页面 DOM 文本快照(含 uid 标记)
clickuid, clientId?点击指定元素(触发 touch + click)
filluid, value, clientId?向输入框填入文本并触发 input/change
scrolluid?, x?, y?, clientId?滚动到元素或坐标
evaluate_scriptcode, clientId?执行任意 JS 代码并返回结果
get_urlclientId?获取当前页面 URL 和标题
screenshotuid?, clientId?截图返回 base64(依赖 html2canvas)
get_textuid, clientId?获取指定元素的文本内容

当只有一个客户端连接时,clientId 可省略。


项目结构

/Users/alsc/Documents/mobile-bridge-mcp/
  ├── src/
  │   ├── bridge-client.js     # 注入手机页面的客户端脚本
  │   ├── bridge-server.ts     # HTTP + WebSocket 桥接服务
  │   └── mcp-server.ts        # MCP Server 入口(内嵌 bridge-server)
  ├── package.json
  └── tsconfig.json

核心模块详解

1. bridge-client.js(手机端注入脚本)

通过 whistle jsAppend 注入到手机页面,核心逻辑:

传输层自动选择

  • HTTPS 页面 → 同源路径长轮询(fetch(location.origin + '/__mb__/...')
  • HTTP 页面 → WebSocket 直连(ws://电脑IP:9300

长轮询通信协议

  1. POST /__mb__/register — 注册客户端,获取 clientId
  2. GET /__mb__/poll?clientId=xxx — 长轮询拉取命令(25秒超时返回 204)
  3. POST /__mb__/response — 回传命令执行结果

DOM 快照

  • 使用 data-mb-uid 属性动态标记可见且可交互的 DOM 元素
  • 输出格式模仿 chrome-devtools-mcp 的 snapshot,便于编码助手理解

诊断标记

  • 脚本执行后会修改页面标题为 [MB] 原标题
  • 注册成功后标题变为 [MB:clientId] 原标题

2. bridge-server.ts(电脑端桥接服务)

Node.js 服务,监听 9300 端口,同时处理:

  • HTTP 路由/register/poll/response/status/bridge-client.js
  • WebSocket:用于 HTTP 页面的直连模式
  • 路径兼容:自动去掉 /__mb__ 前缀,兼容 whistle 转发的同源请求

核心特性

  • 支持多客户端(通过 clientId 区分)
  • 指令通过 msgId 匹配请求和响应
  • 命令超时机制(默认 15 秒)
  • 长轮询挂起等待(25 秒超时)
  • 轮询客户端 30 秒无活动自动清理

3. mcp-server.ts(MCP Server 入口)

基于 @modelcontextprotocol/sdk 实现 stdio 模式的 MCP Server:

  • 内嵌启动 BridgeServer(同进程)
  • 注册 9 个 MCP 工具
  • IDE 打开项目时自动启动

Whistle 配置

在 whistle Rules 中添加两条规则:

# 1. 注入 bridge-client.js 到目标页面
*.ele.me jsAppend://{bridge-client.js}

# 2. 路径匹配: /__mb__/ 开头的请求转发到本机 bridge-server
/\/__mb__\// 127.0.0.1:9300

规则说明

  • 第 1 条:whistle 对 *.ele.me 的 HTTPS 响应做拦截,在 HTML 末尾追加 bridge-client.js 脚本内容
  • 第 2 条:正则匹配所有包含 /__mb__/ 路径的请求,转发到本机 9300 端口的 bridge-server

前提条件

  • whistle 已安装根证书到手机(HTTPS 拦截需要)
  • 手机 WiFi 代理指向电脑 whistle(默认 :8899)

whistle Values 配置

  • 在 Values 中创建 bridge-client.js,内容为 /Users/alsc/Documents/mobile-bridge-mcp/src/bridge-client.js 的完整代码

IDE 注册

.vscode/mcp.json

{
  "servers": {
    "mobile-bridge": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "/Users/alsc/Documents/mobile-bridge-mcp/src/mcp-server.ts"]
    }
  }
}

IDE 打开项目时自动拉起 MCP 进程并监听 9300 端口。


踩坑记录

1. .local 域名不可用

iOS/macOS 上 .local TLD 触发 mDNS (Bonjour) 解析,绕过 HTTP 代理。mobile-bridge.local 的请求永远不会到达 whistle。

2. WebSocket 不走 HTTP 代理

手机浏览器建立 WebSocket 时可能不经过 HTTP CONNECT 代理,导致 wss://假域名 连接失败。

3. Mixed Content 限制

HTTPS 页面无法发起 http://ws:// 请求。所有通信必须是 HTTPS/WSS。

4. 最终解决方案

使用同源路径长轮询:请求页面自身域名 + /__mb__/ 路径前缀,whistle 按路径匹配转发。彻底避免了 DNS、代理、Mixed Content 三大问题。

5. 端口冲突导致 MCP 启动失败

手动 kill 9300 端口进程后,需要 Reload IDE Window 让 IDE 重新拉起 MCP 进程。避免手动启动导致端口冲突。


验证通过的功能

  • get_url — 获取页面标题和 URL
  • evaluate_script — 远程执行 JS(修改页面标题)
  • 长轮询通信稳定(register 200、poll 204/200、response 200)
  • IDE 自动启动 MCP Server
  • HTTPS 页面(h5.ele.me)正常工作