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 处理
关键原理:
- 脚本向页面自身域名发起请求(
location.origin + '/__mb__/...'),属于同源请求 - 同源 HTTPS 请求一定经过 whistle 代理(页面本身就是通过代理加载的)
- Whistle 已对该域名做 HTTPS 拦截(jsAppend 需要),所以能看到请求路径
- Whistle 按路径
/__mb__/匹配规则,将请求转发到本机 bridge-server(HTTP :9300) - 不存在 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_snapshot | clientId? | 获取页面 DOM 文本快照(含 uid 标记) |
click | uid, clientId? | 点击指定元素(触发 touch + click) |
fill | uid, value, clientId? | 向输入框填入文本并触发 input/change |
scroll | uid?, x?, y?, clientId? | 滚动到元素或坐标 |
evaluate_script | code, clientId? | 执行任意 JS 代码并返回结果 |
get_url | clientId? | 获取当前页面 URL 和标题 |
screenshot | uid?, clientId? | 截图返回 base64(依赖 html2canvas) |
get_text | uid, 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)
长轮询通信协议:
POST /__mb__/register— 注册客户端,获取 clientIdGET /__mb__/poll?clientId=xxx— 长轮询拉取命令(25秒超时返回 204)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)正常工作