feat(posts): publish 4-part windows-mcp series across historical dates with full EN+ZH assets
Deploy / build (push) Successful in 22s

This commit is contained in:
hoelee committed 2026-10-25 18:45:43 +08:00
1 parent 4cfbc33a6f
commit d5ab7dec81
36 files changed
+1537 -127

No files matched your search

@@ -0,0 +1,123 @@
---
title: "把鼠标和键盘交给 AI 之前:Windows 桌面 MCP 的安全边界与实战防线"
description: "为什么开箱即用桌面自动化 MCP 存在系统风险?详解如何防范注册表误写、高分屏 Token 溢出与复杂窗口遍历卡死等关键隐患。"
pubDate: 2025-10-22
updatedDate: 2026-10-25
category: ai
tags: ["windows", "mcp", "security", "ai-agents", "automation"]
ogImage: /og/zh-giving-ai-your-mouse-and-keyboard-safely.jpg
banner: /banners/giving-ai-your-mouse-and-keyboard-safely.jpg
draft: false
---
让 AI 模型直接操控操作系统的物理鼠标和键盘,在演示视频里看起来非常惊艳:Agent 看着你的屏幕、点击按钮、输入文本,自动搞定那些没有提供 REST API 也没有 CLI 命令行接口的老旧桌面软件。
**但在生产环境中直接开箱即用,它是一个巨大的运维安全隐患。**
在评估 `windows-mcp`(一个用于让大语言模型连接 Windows UI 自动化操作的 MCP 服务)时,我审查了它的默认工具清单与执行策略。结果发现:它默认把多项宿主机最高权限的功能直接暴露给了模型,中间没有任何隔离。
在把桌面控制权真正交给自治 Agent 之前,必须在传输层设下这四道安全防线。
---
## 1. 立即剥离 PowerShell 与注册表工具
当一个 MCP 服务宣称用于“UI 自动化”时,我们预期它提供的是鼠标位移、键盘快捷键、窗口聚焦和截图识别功能。
但 `windows-mcp` 默认暴露出 21 个工具。在 `Click`、`Type` 和 `Screenshot` 旁边,赫然躺着两个宿主级执行工具:
- `PowerShell`:直接在宿主机执行任意 PowerShell 脚本。
- `Registry`:直接读取、写入、新建和删除 Windows 注册表键值。
如果 Agent 在尝试“排查某个无法启动的本地软件”时,从过时的网络论坛幻觉出一段注册表修改指令,可能会直接破坏 `HKLM\SOFTWARE` 下的关键系统项。更严重的是,一旦它读取的文件或网页中存在 Prompt 注入攻击,恶意指令就能通过未隔离的 PowerShell 在你的宿主机上静默执行。
### 修复方案
绝不能仅仅依靠提示词告诉模型“请不要动注册表”。必须在启动层直接通过 `--exclude-tools` 阻断注册:
```bash
uvx windows-mcp serve --exclude-tools "PowerShell,Registry"
```
这会将对外暴露的工具集严格缩减至 19 个纯 UI 与进程管理工具。MCP 服务不暴露的接口,模型在协议层就无法调用。
---
## 2. 在高分屏与多屏环境下开启强制缩放
现代开发机环境普遍配备 2K(2560×1440)、4K 或多显示器拼接虚拟桌面。
当 Agent 为了定位一个元素而截取全屏快照时,原始画布尺寸的文件经常超过 2.5 MB。这会直接导致两类致命问题:
1. **MCP 载荷上限截断**:多数 MCP 客户端(如 Claude Desktop 或终端 Agent)对单次工具返回结果有 1 MB 的尺寸限制,全量 4K 截图会直接触发 `PayloadTooLarge` 报错中断。
2. **上下文 Token 快速耗尽**:超大分辨率图片会吃光上下文窗口的 Token 预算,造成推理成本飙升并把先前的会话记忆过早挤出缓存。
### 修复方案
通过环境变量强制设置截图缩放比例:
```bash
export WINDOWS_MCP_SCREENSHOT_SCALE="0.5"
```
0.5 的缩放将横纵像素各减半,在不影响元素识别的前提下将体积削减 75%。
*坐标换算说明:* 启用缩放后,工具元数据会提示 `Screenshot Coordinate Scale: 2.0`。模型在读取缩放图像的像素坐标后,传给 `Click` 或 `Move` 时需要乘以 `2.0` 换算回实际物理屏幕像素。
---
## 3. 将复杂 Electron 进程加入遍历黑名单(Issue #383)
`windows-mcp` 提供了两种屏幕感知模式:
- `Screenshot`:快速纯视觉截图,不提取底层 UI 元素树。
- `Snapshot`:深度 UI 树遍历,提取所有控件的包围盒、元素类型与 ID。
如果屏幕上开着 VS Code(`Code.exe`)或 Cursor(`Cursor.exe`)等具备庞大 DOM 树的应用,调用 `Snapshot` 时 Windows UIAutomation 树遍历极易发生性能阻塞,导致 Agent 在长达 30 至 60 秒内完全假死。
### 修复方案
利用进程排除名单,直接跳过重型编辑器窗口的树遍历:
```bash
export WINDOWS_MCP_EXCLUDE_PROCESSES="Code.exe,Cursor.exe"
```
自动化引擎将忽略开发工具的内部结构,只专注于我们要操控的业务窗口。
---
## 4. 彻底关闭截图屏幕闪烁与遥测上传
默认配置下,每次截图后 `windows-mcp` 会在屏幕四周渲染一圈橙红色的发光矩形边框。在连续执行的多步自动化流程中,频繁的强光闪烁会对正在同台电脑前工作的用户造成严重视觉干扰。
此外,服务默认会向 PostHog 发送匿名使用遥测。
### 修复方案
通过环境变量彻底关闭闪光与遥测:
```bash
export WINDOWS_MCP_DISABLE_FLASH="1"
export ANONYMIZED_TELEMETRY="false"
```
---
## 生产级完整配置模版
结合上述四道防线的标准 stdio 配置如下:
```json
{
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
```
让 AI 拥有操控桌面的能力确实能够补全很多遗留系统的自动化断点,但前提是必须拥有严密的防守边界。把控制权建立在协议防线之上,永远比寄希望于大模型自觉遵守提示词更可靠。
@@ -7,6 +7,7 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
updatedDate: 2026-10-23
---
## 这次和过去十个「PHP 高危漏洞」不一样
@@ -25,7 +26,7 @@ draft: false
## 停止猜,改成实测
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 5 个容器化的 PHP 站)。加起来大概 170 个容器。
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 6 个容器化的 PHP 站)。加起来大概 170 个容器。
我第一反应是看镜像 tag 就收工——这恰恰是最容易出错的判断。`php:8.3-fpm-alpine` 是浮动 tag,它可以是任何 8.3.x;而一个从未被重建过的容器,永远停在它启动那一刻的版本。**Docker 的 tag 不是版本号。** 所以我改成进入每一个候选运行时里量:在容器里、或在宿主运行时自己的二进制上跑 `php -v` 和 `php -i`。
@@ -33,13 +34,13 @@ draft: false
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| VPS 41.216.187.102 | 5 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(5 个站) | 有——8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | 仅内部 | 有——8.2.35 |
| VPS 41.216.187.102 | 6 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(6 个站) | 有——8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | 公网(它自己的 web UI) | 有,藏在 mailcow 2026-10 里 |
| CyberPanel VM | `lsphp84`(8 个站) | **8.4.25** | 公网(8 个站) | 有——8.4.27 |
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | 未使用 / 默认回退 | 8.1 永远没有,其余有 |
| DSM | Web Station PHP 套件 | **8.2.30 / 8.3.30 / 8.4.19** | 内网 + 隧道 | **没有** |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | SSO 后面 | 有——重拉镜像 |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | SSO / 内网 | 需要重拉并复核 |
| DSM | Web Station PHP 套件 | **8.2.30 / 8.3.30 / 8.4.19** | 内网 + 隧道 | 当时还没有——22 日 19:45 才出 |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | SSO 后面 | 有——同一个 tag 被重建了 |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | SSO / 内网 | 有——5.17.0 带 8.4.27 |
| unRaid 7.1.4 | webGUI 的 PHP | **8.3.19** | 仅内网 | **没有** |
每一行都中。这不是巧合,这就是「只给应用打补丁、不给运行时打补丁」的机群长什么样,而这类 bug 已经在代码里躺了十年。
@@ -147,7 +148,7 @@ nestz.com.my 200 222780 (一致)
truetrading.com.my 200 134535 (一致)
test.hoelee.com 200 80869 (一致)
sifumail.com 200 44861 (一致)
numerology.digikedai.com 200 142762 (留在 8.4.27)
numerology.digikedai.com 200 142762 (后来迁到 8.5.12,见下文)
```
我还会在每个响应里 grep `Fatal error` / `Uncaught` / `Deprecated:`,并请求 `/wp-json/`——那里返回 200 说明 WordPress 真的启动、查库、序列化了一份响应,比一个可能被缓存的首页渲染强得多。全部零错误。(WordPress 6.9 与 7.x 官方声明完全支持 PHP 8.5,残余风险在插件不在核心。我的站每个装了 19–44 个插件,这正是字节数校验值得做的原因。)
@@ -167,15 +168,81 @@ numerology.digikedai.com 200 142762 (留在 8.4.27)
教训不是「检查括号」。而是:**会静默失效的安全控制,比没有控制更糟**——它把信心卖给了你,却没给保护,而本该告诉你真相的那条警告,正躺在你已判定为噪音的日志里。
## 我故意没有升级的部分
## 厂商补丁在我干活干到一半时来了
其中有几个系统根本没有补丁可打,把这件事说清楚也是工作的一部分。
我本来已经把 DSM 判死了。群晖归档里最新的 `PHP8.4` 是 8.4.19-0050,所以 Web Station 接下来的几周只能靠 `session.upload_progress.enabled = 0` 加一条防火墙规则撑着。然后 10 月 22 日 19:45,群晖发布了 SA-26:14,带的是真的修复包。几小时后我把它装上了:
**群晖 DSM** 的 PHP 是以 Package Center 套件形式发布的。群晖公开归档里最新的 `PHP8.4` 仍是 **8.4.19-0050**——修复前版本,而群晖的 PHP 套件历来落后几天到几个月。所以 Web Station 只做缓解、不做升级:PHP 配置里 `session.upload_progress.enabled = 0`、`session.use_strict_mode = 1`、`session.use_only_cookies = 1`,并立一条硬规矩——运行时没打补丁期间,Web Station 的站点根绝不暴露到公网。
```text
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
```
**unRaid** 把 PHP 打包在系统镜像里。我这台是 7.1.4,webGUI 的 PHP 是 **8.3.19**;当前稳定版(7.3.3)、甚至 7.4.0-rc.1 带的都是 **8.4.26**——仍然低于 8.4.27。没有补丁可打,所以控制手段是可访问性:webGUI 只在内网应答,没有走隧道。
麻烦的是访问权。那台机器的 SSH 只认一个我手上没有的密码,管理界面要登录,而能装 `.spk` 的只有宿主机上的 `synopkg`。我手里有的是这台 NAS 的 Docker API。于是让包管理器在一个特权容器里跑,把宿主根目录挂进去,用 `chroot` 调用它:
**numerology.digikedai.com** 是我主动留在 8.4.27 的。它是 CodeIgniter 4 应用,框架版本 4.7.4,官方文档只声明支持到 PHP 8.4。一个处理订单和交付的应用,是最不该拿去试 8.5 弃用警告的地方——所以它现在拿的是「已打补丁的运行时」,分支迁移放到框架升级 + staging 测试之后。
```python
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # 关键就是这一行
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# 然后在容器里:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
```
`PidMode` 那一行就是全部的教训。我第一次漏了它,`synopkg` 回的是:
```json
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
```
二进制还停在旧版本,而包已经死了,状态是 `broken / install_corruption`。用 `PidMode: host` 重跑同一个安装包,走的是修复路径(`stage: installed_and_started`),一次过。光挂 `/proc`、`/sys`、`/dev`、`/run` 不够——PID 命名空间必须是宿主机的。
还有两个小坑。SPK 直链的架构写在文件名里(`PHP8.4-x86_64-8.4.27-0051.spk`),去掉 `-x86_64-` 就是 404。另外,跑 Calendar 的那个服务并不是你刚重启的那个:`synopkg restart PHP8.4` 会回你「成功」,而 Calendar 的 FPM 还在跑旧二进制,因为 Calendar 用的是 **PHP 8.2**——`pkg-synocalendar-phpfpm.service`,`ExecStart=/usr/local/bin/php82-fpm`。得按名字重启那个 unit,然后从二进制本身证明版本,而不是相信 Package Center 的显示:
```text
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
```
有一个副作用值得点名:装包会把包启动起来。那台机器上的 PHP 8.3 一直是「装了但没跑」的状态,升级把它拉了起来,等于凭空多给我一个监听面,于是我把它停回去。另外 Calendar 这个包本身还差一个版本(3.0.3-21434,公告要求 3.0.4-21435,SPK 还没上公开 CDN),但它应答请求用的运行时已经打好了——那才是要紧的部分。
## 同一个 tag 被重建过,就是另一个运行时
`phpmyadmin:5.2.3` 是上面那套「浮动 tag 不可信」的反例,也是我现在除了版本号还要看重建日期的原因。
tag 从我部署那天起就没变过,里面的 PHP 变了:镜像在 10 月 21 日被重建,烘进去的是 PHP **8.3.36**。所以修法是 `pull` 加重建容器,不用改版本号、不用读 changelog——容器里现在报 `PHP 8.3.36 (built: Oct 21 2026)`,之前是 8.3.33。
这次 pull 的代价也不只在本地。通过 API 重建那一个 stack,顺带把 `mysql:8.4.11` 重新解析了一遍,四个容器重启了大约三十秒。没有东西坏掉,但「只动一个容器」这种改动,值得在落地过程中盯着,而不是事后才发现。
## 我说不能动的那个应用
我原本把 `numerology.digikedai.com` 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 `composer.json` 允许 `^8.2`,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 `E_ALL` 加载干净:`spark routes`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
这次切换还牵出第二个跟 CVE 毫无关系的运行时。应用的三条 cron 调的是 `/usr/bin/php`——发行版自带的 PHP 8.3.6 CLI,没有 `ext-intl`——而站点本身走的是 LiteSpeed 的构建。失败又安静又彻底:`Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189`,自动出件和发信队列两个 worker 日志里各有 429 行,每一次定时任务都在干活之前就死了。把三条 crontab 指到 `/usr/local/lsws/lsphp85/bin/php`(也就是站点自己用的那个二进制)之后才结束。
我看的时候队列里正好没有卡住的信。那是运气,不是设计——那两条 cron 正是已付款订单出件和收据寄出的通道。
## 那个「复制了个寂寞」的备库
升级第二台 passbolt 时,冒出了那晚最不舒服的一个发现。
主库(5.15.0)和灾备备库(5.14.0)跑的都是 PHP 8.4.24,都需要换到 5.17.0 镜像,那里面才是 8.4.27。备库起来后,它自带的每一项检查都通过——指纹在、私钥能解密、JWT 对有效——只不过它的数据库是 **34 张表,主库是 56 张**,而 `Seconds_Behind_Source: 0`。
两个数字都是真的,但都不是看上去的意思。复制从来没带上 passbolt 的写入:主库的 binlog 被 `--binlog-do-db=appdb` 过滤着,而 appdb 这个库早就不存在了,所以那台机器上的每个 binlog 都是 181 字节。空流上的 `Seconds_Behind_Source: 0` 不是健康,是打着绿勾的沉默。
修它试了三次,中间有一个真坑。`--replicate-do-db=passbolt` 会按会话默认库去过滤 DDL,于是复制过来的 `CREATE TABLE` 被丢掉,而后面跟着的插入照样匹配——SQL 线程就停在 `Table 'passbolt.x' doesn't exist`。能用的是 `--replicate-wild-do-table=passbolt.%`,它按真实表名匹配,DDL 和 DML 都管。然后全量重灌(`--source-data=2`,让 dump 自带复制坐标)、`CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1`(非 TLS 链路上不带这个参数,`caching_sha2_password` 认证不过)、再把 `read_only` 收回去。验证方式是两边对账(56 张表、2 users、542 resources、644 secrets、525 permissions),以及往主库写一行探针、看它在五秒内出现在备库上。这个故事值得单独一篇。这里的重点是:给运行时打补丁,同时也是一次审计,而这次审计发现了一套一直只是摆设的灾备方案。
## 还没修好的部分
**unRaid** 把 PHP 打包在系统镜像里。我这台是 7.1.4,webGUI 的 PHP 是 **8.3.19**;当前稳定版(7.3.3)甚至 7.4.0-rc.1 带的都是 **8.4.26**——仍然低于 8.4.27。没有补丁可打,所以控制手段是可访问性:webGUI 只在内网应答,没有走隧道。
**mailcow** 是我第一轮判断错的地方。我把它的 `phpfpm` 容器(8.2.29)归成了「仅内部」,它不是:它的 web UI 在一个公网主机名上应答。镜像被 mailcow 的发布版本钉死(`ghcr.io/mailcow/phpfpm:8.2.29-3`),所以没有单独升的余地——修复是随 mailcow 自己的版本发出的,2026-10(10 月 21 日)带的是 `phpfpm:8.2.35-1`。那是一次带停邮窗口的应用升级,不是拉个镜像,所以它要单独排一个窗口。在那之前这个容器是修复前状态,把这句话写下来,比做一张看起来全都完事的表更有用。
## 如果重来一次,我会改什么
@@ -183,12 +250,25 @@ numerology.digikedai.com 200 142762 (留在 8.4.27)
2. **把面板数据库当成运行时状态来看。** 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
3. **当天就删旧分支。** 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
4. **每次都把配置测试的输出读出来。** 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
5. **假设厂商补丁会在事故中途到达。** 我早上把 DSM 判死,当天 19:45 就拿到了修复包。那份缓解方案本来只是占位,我却开始把它当成结论——在把「没有修复版本」当成发现写出去之前,回头再看一次公告。
## 结果
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。接下来一小时里,其中 7 个迁到 PHP **8.5.12**,第 8 个按计划留在 **8.4.27**,回退分支升到 **8.3.36**,两条过时分支被删除——每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。到那晚结束时,22 号量过的每一个运行时都动了,除了两个确实没有可装的:
顺带还拿到一个真实发现:八条从未生效的 bot-block 规则,在一个公网站点上,在一个解析器一直在抱怨的配置文件里。
| 位置 | 现在 | 怎么做的 |
|---|---|---|
| 8 个 CyberPanel 站 | **8.5.12** | 留 lsphp83 8.3.36 作回退 |
| 6 个 VPS 容器 | **8.3.36** | 浮动 tag,重建镜像 |
| DSM Web Station | **8.2.35 / 8.3.36 / 8.4.27** | SA-26:14 套件 |
| DSM phpMyAdmin | **8.3.36** | 同一个 tag,重建镜像 |
| passbolt 主库 + 备库 | **8.4.27** | 5.17.0 |
| mailcow php-fpm | 8.2.29 | 被 mailcow 版本钉死——2026-10 已排期 |
| unRaid webGUI | 8.3.19 | 厂商没有补丁 |
每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
同一次扫荡还带出两个跟 PHP2Shell 无关的发现:八条从未生效的 bot-block 规则,在一个公网站点上;以及一个「一直在复制、其实什么都没复制」的备库。
PHP 的零日很少见。静默失效的安全控制不少见。
@@ -0,0 +1,197 @@
---
title: "把 Windows 桌面控制接进 4 款 AI Agent:Hermes、OpenCode、Claw 与 Claude 的实战架构"
description: "详解如何为 Hermes Agent、OpenCode、Claw Code 与 Claude Code 统一接入 windows-mcp 桌面自动化服务,实现零配置漂移。"
pubDate: 2025-12-23
updatedDate: 2026-10-25
category: ai
tags: ["windows", "mcp", "ai-agents", "hermes", "opencode", "claw-code"]
ogImage: /og/zh-plugging-windows-desktop-control-into-four-ai-agents.jpg
banner: /banners/plugging-windows-desktop-control-into-four-ai-agents.jpg
draft: false
---
在多 Agent 协同的日常开发体系中,针对不同场景调度专用 Agent 已成为常态。在我管理的这台 Windows 工作站上,常驻着 4 款不同的 AI 代码助手:
- **Hermes Agent**:Nous Research 开发的主力 Agent,具备强大的多模态调度与终端工具链。
- **OpenCode**:自带 TUI 界面的开源多模型编程 Agent。
- **Claw Code**:基于 Rust 重写的 Claude Code 纯净分支(`claw.exe`)。
- **Claude Code**:Anthropic 官方的代码助手。
虽然它们在编写代码、调用终端命令和修改文件上表现出色,但当面对没有 REST API 也没有 CLI 接口的老旧 Windows 客户端、系统级原生文件对话框或桌面弹窗时,它们默认都无法直接操作。
开源的 `windows-mcp` 正好填补了这一空白,它通过 Model Context Protocol 暴露了 Windows 原生 UIAutomation、鼠标键盘模拟以及窗口管理工具。以下是我如何在 4 个 Agent 客户端中统一接入并避免配置漂移的完整实录。
---
## Python 3.14+ 运行基线
`windows-mcp` 有一个明确的硬性环境要求:**必须使用 Python 3.14+**。在 Windows 机器上手工安装多版本 Python 并维护软链接极易发生环境变量冲突。
最稳妥的方案是借助 `uv` 的瞬态执行器 `uvx`。各个 Agent 统一通过以下命令启动:
```bash
uvx windows-mcp serve
```
`uvx` 会按需拉取隔离环境、预装 PyWin32 与 Pydantic 依赖,通过标准输入输出(`stdio`)管道与 Agent 进程通信,完全不污染系统的全局 Python 环境。
---
## 1. Hermes Agent 接入
Hermes 内置原生 MCP 客户端,配置收敛在 `~/.hermes/config.yaml` 中。
可以直接使用 Hermes CLI 的工具发现命令一键注册:
```bash
printf 'y\n' | hermes mcp add windows-mcp --command uvx \
--env WINDOWS_MCP_CONTROL=false WINDOWS_MCP_SCREENSHOT_SCALE=0.5 WINDOWS_MCP_DISABLE_FLASH=1 WINDOWS_MCP_EXCLUDE_PROCESSES=Code.exe,Cursor.exe ANONYMIZED_TELEMETRY=false \
--args windows-mcp serve --exclude-tools PowerShell,Registry
```
该命令会自动向 `~/.hermes/config.yaml` 写入如下规范结构:
```yaml
mcp_servers:
windows-mcp:
command: uvx
args:
- windows-mcp
- serve
- --exclude-tools
- PowerShell,Registry
env:
WINDOWS_MCP_CONTROL: "false"
WINDOWS_MCP_SCREENSHOT_SCALE: "0.5"
WINDOWS_MCP_DISABLE_FLASH: "1"
WINDOWS_MCP_EXCLUDE_PROCESSES: Code.exe,Cursor.exe
ANONYMIZED_TELEMETRY: "false"
```
验证连接:
```bash
$ hermes mcp list
MCP Servers:
Name Transport Tools Status
──────────────── ────────────────────── ──────── ──────────
windows-mcp uvx windows-mcp serve all ✓ enabled
```
---
## 2. OpenCode 接入
OpenCode 统一读取 `~/.config/opencode/opencode.jsonc` 配置文件。
在根节点的 `"mcp"` 对象中追加 `windows-mcp` 本地服务配置:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"windows-mcp": {
"type": "local",
"command": ["uvx", "windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
"enabled": true,
"environment": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
验证 OpenCode 连接状态:
```bash
$ opencode mcp list
┌ MCP Servers
│
● ✓ windows-mcp connected
│ uvx windows-mcp serve --exclude-tools PowerShell,Registry
│
└ 1 server(s)
```
---
## 3. Claw Code (claw.exe) 接入
Claw Code(v0.1.7)支持在项目目录的 `.claw/settings.json` 或用户主目录的 `~/.claw/settings.json` 中配置 MCP:
```json
{
"mcpServers": {
"windows-mcp": {
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
在终端中验证 Claw Code 状态:
```bash
$ claw mcp
MCP
Working directory C:\Users\hoelee
Configured servers 1
Total entries 1
Invalid entries 0
windows-mcp stdio user uvx windows-mcp serve --exclude-tools PowerShell,Registry
```
---
## 4. Claude Code 接入
Claude Code 的全局配置文件为 `~/.claude.json`,结构完全兼容:
```json
{
"mcpServers": {
"windows-mcp": {
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
---
## 端到端实跑验收
在完成 4 处配置写入后,我编写了一段 Python 探针脚本,通过标准 `mcp` 客户端对真实工具进行了全链路测试:
1. **`DisplayInventory`**:准确识别出本机双屏幕布局(1080p 主屏 + 1440p 扩展屏,虚拟桌面边界为 `left: -313, top: -1440, right: 2247, bottom: 1080`)。
2. **`Screenshot`**:成功完成按 0.5 比例下采样的屏幕截图捕获,并如实返回了物理鼠标位置(`1318, -714`)。
3. **`Notification`**:在 Windows 11 操作中心成功弹出发起一条原生 Toast 提示。
通过保持命令参数与环境变量在 4 款 Agent 之间的一致对齐,无论当前在哪一个终端或界面中工作,当代码与命令行无法触达原生桌面软件时,Agent 都能随时调起标准的桌面自动化工具。
@@ -0,0 +1,107 @@
---
title: "什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)"
description: "AI Agent 四层自动化架构决策模型:深度对比确定性 API、命令行脚本、后台无感无接触操控与物理桌面模拟的选型边界。"
pubDate: 2026-05-28
updatedDate: 2026-10-25
category: case-studies
tags: ["ai-agents", "automation", "windows", "computer-use", "architecture"]
ogImage: /og/zh-when-to-give-your-agent-physical-clicks.jpg
banner: /banners/when-to-give-your-agent-physical-clicks.jpg
draft: false
---
## 商业价值与背景:通用桌面 AI 的幻觉
在很多关于自主智能体(AI Agent)的公开演示中,桌面操控的效果最具视觉冲击力:大模型看一眼屏幕截图,计算出按钮所在的 (x, y) 像素坐标,物理光标在屏幕上划过并精准完成点击。
对于正在寻求企业内部降本增效的企业主和技术主管来说,这看起来像是一颗银弹:*“我们不再需要花费昂贵成本去订制 API 接口了,直接买一套 AI 软件,让它像真人员工一样对着电脑点点点不就行了?”*
但在真正的生产运维实践中,如果把模拟物理 GUI 作为主力自动化方案,往往会变成灾难。物理点击极其脆弱、耗时漫长、极易被系统通知弹窗阻断,而且会严重干扰坐在同一台电脑前正常工作的真人。
在过去一年将各类 AI 工作流推向实际业务的过程中,我沉淀出了一套**四层自动化决策阶梯**。只有搞清楚各层的优劣,才能让物理点击工具真正发挥价值。
---
## 四层自动化梯队模型
每一个交由 AI Agent 处理的任务,都应尽可能在最底层、最具确定性的梯队解决:
```text
第一梯队:确定性 API / 数据库直连 (耗时:~10ms · 可靠性:99.99%)
│
第二梯队:无头 CLI / 子进程命令行 (耗时:~200ms · 可靠性:99.9%)
│
第三梯队:后台无感无障碍驱动 (CUA) (耗时:~1-2s · 可靠性:95%)
│
第四梯队:前台物理输入模拟 (MCP) (耗时:~3-5s · 可靠性:80%)
```
### 第一梯队:确定性 API 与数据库直连
只要系统暴露了数据端点,绝不要碰 UI 界面。操作 MySQL、Docker、Portainer 或支付网关,必须通过 RESTful API、SQL 语句或受检的 SDK 完成。这种方式毫秒级响应、上下文完全隔离,且返回的是结构化数据而非含糊的像素点。
### 第二梯队:无头 CLI 与子进程
如果没有现成的网络 API,第二优先级是寻找命令行工具。代码修改、文件处理、日志分析或视频压制,完全可以通过 `terminal`、`git`、`ffmpeg` 或本地脚本静默执行。模型无需移动鼠标,不仅速度快出几十倍,而且不会因界面卡顿而中断。
### 第三梯队:后台无感无障碍驱动(CUA)
当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如 Hermes 内置基于 `cua-driver` 的 `computer-use`)。
- 指令通过系统底层辅助功能框架(Windows UIA / macOS AX)直接派发给目标控件。
- **真人的物理鼠标光标完全不会被移动。**
- 窗口不需要前置聚焦。你可以在前台敲代码或写邮件,而 Agent 在后台最小化或未激活的窗口里完成点击。
### 第四梯队:前台物理输入模拟(Windows-MCP)
第四梯队是最后的防线:调用类似 `windows-mcp` 的工具,截取屏幕、计算全屏像素坐标,并通过系统底层的 `SendInput` 派发真实的硬件级鼠标键盘事件。
---
## 什么时候真正需要第四梯队(物理点击)?
尽管第四梯队的成本最高、脆弱性最强,但在以下三大现实业务场景中,前三梯队会彻底失效,这正是 `windows-mcp` 展现价值的地方:
### 1. 闭源陈旧的 Win32 遗留企业软件
许多使用 Delphi、VB6 或老旧 C++ 编写的中小企业财务、仓储、ERP 客户端,根本没有实现现代 Windows UIAutomation 接口。后台 UIA 树只能看到一个空白容器,也没有任何命令行接口。此时,结合视觉截图(`Screenshot`)与物理坐标点击(`Click`)是让 AI 能够接管这套流程的唯一技术手段。
### 2. 操作系统原生文件选择与保存对话框
在自动化驱动网页导出报表或桌面软件另存为时,经常会弹出 Windows 原生文件对话框(`comdlg32.dll` / 资源管理器窗口)。无头浏览器无法穿透操作系统的原生对话框,而 `windows-mcp` 可以识别“另存为”输入框、填入绝对路径,并通过键盘快捷键 `Enter` 触发保存。
### 3. 跨非联网软件的剪贴板与系统级提示
两款互不联网的单机软件之间流转非结构化数据时,常需要借助系统剪贴板(`Clipboard` 的读取与写入)完成中转;或者在任务结束时,通过系统的原生 Toast 弹窗(`Notification`)提示人工介入。
---
## 典型反面模式:哪些事情绝对不要用物理点击?
在代码审查和自动化诊断中,我经常看到把第四梯队当成万能钥匙的典型反模式:
- **让 Agent 在编辑器里物理打字写代码**:让 AI 截图、点击 VS Code 编辑区、模拟敲键盘写代码不仅速度奇慢、极易漏字,还会巨额消耗图像 Token。应直接使用文件工具(`patch`、`write_file`)。
- **通过点击图形浏览器来抓取公开网站数据**:物理操作前台 Chrome 浏览器的稳定性远低于无头浏览器或直接发起 HTTP 请求。
- **人在使用电脑时触发物理操作**:因为第四梯队会抢占真实鼠标和键盘焦点,如果你正在打字回复客户,物理点击会瞬间夺走焦点并导致输入错乱。
---
## 选型矩阵总结
```text
┌────────────────────────────────────────────────────────┐
│ 自动化架构选型矩阵 │
├──────────────────────┬─────────────┬───────────────────┤
│ 业务场景 │ 正确工具 │ 所属梯队 │
├──────────────────────┼─────────────┼───────────────────┤
│ 数据库查询 / API交互 │ REST / SQL │ 第一梯队(API) │
│ 代码维护 / 运维脚本 │ Terminal │ 第二梯队(CLI) │
│ 无需前置的软件自动化 │ cua-driver │ 第三梯队(后台UIA)│
│ 遗留Win32 / 另存对话框│ windows-mcp │ 第四梯队(物理模拟)│
└──────────────────────┴─────────────┴───────────────────┘
```
把自动化视为一个逐步升级的阶梯,而不是一把万能锤子,是区分脆弱的实验室 Demo 与能够连续稳定运行数月的生产级工作流的分水岭。
---
## 想为你的企业构建这套自动化体系?
我为马来西亚及海外中小企业量身定制并审查 AI 自动化管线——从纯后台的数据流转、智能客服 Agent 到跨越遗留桌面软件的自动化中转。如果你的团队每天仍在耗费大量人工在老旧软件之间机械地搬运数据,我可以为你搭建 7×24 小时高可靠运行的自动化架构。
直接联系我:
- **WhatsApp**: [+60 12-797 2969](https://wa.me/60127972969)
- **邮箱**: [[email protected]](mailto:[email protected]?subject=AI%20桌面自动化咨询)
- **主页**: [hoelee.com](https://hoelee.com)
@@ -0,0 +1,139 @@
---
title: "为什么你的 Windows 桌面 MCP 调用会静默失败:攻破 10 秒空闲门禁与避坑实录"
description: "排查 windows-mcp 调用返回 CONTROL_PREEMPTED 的深层机制,以及为什么必须通过环境变量彻底禁用桌面控制门禁。"
pubDate: 2026-03-12
updatedDate: 2026-10-25
category: notes
tags: ["windows", "mcp", "debugging", "ai-agents", "troubleshooting"]
ogImage: /og/zh-why-your-windows-mcp-calls-silently-fail.jpg
banner: /banners/why-your-windows-mcp-calls-silently-fail.jpg
draft: false
---
你刚装好 `windows-mcp`,将其加到 Agent 的配置中,然后让它发送一条 Windows 系统通知,或者点击某个打开的应用窗口按钮。
工具调用在不到 200 毫秒内就返回了,大模型语气轻松地向你汇报:*“通知已成功发送。”*
**然而屏幕上什么都没有发生。**
当你把 MCP 协议传输的底层原始 JSON 结果打印出来时,既没有看到标准的 Python 异常,也没有看到 Win32 系统错误,而是看到这样一段内容:
```json
{
"code": "CONTROL_PREEMPTED",
"status": {
"state": "user",
"generation": 3,
"wait_seconds": 9.8639,
"executed_steps": 0
}
}
```
或者在不同的时序下,后台进程直接抛出一串异常堆栈:
```text
RuntimeError: AI control indicator is stopping
AI control indicator failed; releasing physical input
```
这就是导致大部分桌面 MCP 调用静默失败的隐蔽真凶。
---
## 根因剖析:桌面控制门禁(Desktop Control Policy)
`windows-mcp` 内部设计了一套**桌面所有权控制机制**(位于 `src/windows_mcp/desktop/control_overlay.py` 与 `tools/control_policy.py`)。
开发者为了避免 AI 在物理桌面上与正在操作鼠标键盘的真人“抢夺输入焦点”,实现了一个空闲检测看门狗:
1. 在执行任何具有副作用的输入工具(`Click`、`Type`、`Move`、`Shortcut`、`Notification` 等)前,看门狗会通过 Win32 API `GetLastInputInfo` 读取最后一次物理输入的时间戳。
2. 如果人类在过去 **10 秒**内(`WINDOWS_MCP_CONTROL_IDLE_TIMEOUT`)移动过鼠标或敲击过键盘,服务端判定此时“人类正在使用电脑”。
3. 此时它**既不排队也不等待**,而是立刻主动放弃执行,直接返回 `CONTROL_PREEMPTED`(控制权被抢占),工具实际执行步骤数为 0。
但现实使用场景是:你在终端敲完回车下达指令,或者在聊天框点下发送——**这意味着你刚刚操作过键盘鼠标**。你的系统空闲时间必然小于 1 秒,因此 Agent 随后的第一次工具调用几乎 100% 触发抢占拦截,导致操作直接被吞。
---
## 为什么 `--no-control` 参数不管用
翻看该项目的 README 或文档,你可能会看到关于 `--control / --no-control` 的说明。
很多人第一反应是给命令加上 `--no-control`:
```bash
uvx windows-mcp serve --no-control
```
但服务会在启动瞬间直接崩溃报错:
```text
Usage: windows-mcp serve [OPTIONS]
Error: No such option '--no-control'.
```
这是因为该项目基于 Click 库构建 CLI,`--no-control` 只绑在顶层或其他模式上,`serve` 子命令的参数解析器中根本没有注册这个 CLI 标志。
---
## 真正有效的解法:WINDOWS_MCP_CONTROL=false
通过审查代码可以发现,配置加载模块(`src/windows_mcp/config.py`)会优先读取系统环境变量。
要彻底绕过这道 10 秒空闲限制,允许 Agent 在下达指令后立即获得执行权限,只需注入环境变量:
```bash
WINDOWS_MCP_CONTROL=false
```
将其填入 Agent 的配置文件 `env` 字段:
```json
{
"command": "uvx",
"args": ["windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
"env": {
"WINDOWS_MCP_CONTROL": "false"
}
}
```
此时控制层覆盖(Overlay)、空闲看门狗以及拦截抢占逻辑将被完全旁路关闭。
---
## 验证与验收
为了验证问题已彻底根除,在鼠标持续移动的状态下通过原生 Python MCP 客户端发起 Toast 通知测试:
```python
import asyncio
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
async def main():
params = StdioServerParameters(
command="uvx",
args=["windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
env={"WINDOWS_MCP_CONTROL": "false", "ANONYMIZED_TELEMETRY": "false"}
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
res = await session.call_tool("Notification", arguments={
"title": "Windows-MCP Ready",
"message": "Immediate execution verified.",
"app_id": "Hermes Agent"
})
for c in res.content:
print(c.text)
asyncio.run(main())
```
终端实时输出:
```text
Notification sent: "Windows-MCP Ready" - Immediate execution verified.
```
无需苦等 10 秒倒数,再无 `CONTROL_PREEMPTED`,所有桌面动作即时执行。