先给结论:Windows-MCP 不是"AI 看屏幕点鼠标",它把 Windows 的无障碍树当成了 API。 大多数 computer-use 方案走的是「截图 → 视觉模型猜坐标 → 点击」,代价是每一步都烧一张图、每一步都可能点偏、每一个模型都要单独适配。Windows-MCP 换了一条路:用 UIAutomation 把当前桌面翻译成一棵带 id 的元素树,让 LLM 直接说"点第 37 号按钮"。这决定了它所有的优点(快、省 token、任意 LLM 可用)和所有的缺点(依赖目标应用的无障碍实现质量)。

目录
- 它是什么:一份数据快照
- 实测:装一遍,看它到底什么样
- 真问题:视觉猜坐标走不通
- 分层架构:从 main 到 uia
- 两条感知路径:Screenshot 与 Snapshot
- 树抓取的三个工程约束:预算、排除、线程
- 21 个工具的能力矩阵
- DOM 模式:浏览器自动化的特殊通道
- 桌面租约:当 AI 和你抢同一只鼠标
- 物理输入接管与输入账本
- 安全面:默认关闭的那些开关
- 发布工程:四处版本号与一道守卫
- 它自己承认的边界
- 什么时候该用它,什么时候不该
- 成熟度评估
- 结语
一、它是什么:一份数据快照
一句话:一个跑在本机 Windows 上的 MCP server,把 21 个桌面操作工具暴露给任意支持 MCP 的 LLM 客户端。
数据快照(2026-10-18 通过 GitHub API 与仓库文件核实):
| 维度 | 数据 |
|---|---|
| 仓库 | CursorTouch/Windows-MCP |
| 定位 | MCP Server for Computer Use in Windows |
| Stars / Forks | 9,387 / 1,013 |
| Open Issues | 25 |
| 首次提交 | 2025-05-13 |
| 累计提交 | 775(末页 page=775) |
| 当前版本 | 0.8.8(2026-10-10 发布) |
| 主语言 / 运行时 | Python 3.14+ |
| 许可 | MIT |
| 依赖体量 | 17 个运行时依赖,无深度学习框架 |
| 作者 | Jeomon George(pyproject.toml 署名),组织 CursorTouch |
注意 Python 3.14+ 这条硬门槛:不是建议,是 requires-python = ">=3.14"。这意味着 Windows 10 是它的实际下限(3.14 官方支持 Windows 10 及以上),也意味着很多企业环境里预装的 3.11/3.12 跑不起来,必须靠 uv 拉一个独立解释器。这道门槛劝退了一批人,但也让它能用上 3.14 的自由线程构建与新版类型语法。
一个容易被忽略的细节:pyproject.toml 里 Development Status :: 4 - Beta。9.4k star 但自我标注 Beta,这个组合本身就说明了它的成熟度定位——能用、有人在生产里用,但作者不承诺 API 稳定。
顺带记一个文档内部的小矛盾:README 的 Key Features 写"典型动作延迟 0.2–0.5 秒",而 manifest.json 里给商店用的描述写的是"0.2–0.9 秒"。两个数字出自同一仓库、同一版本,说明这个指标没有统一测量口径(不同机器、不同目标应用、不同截图后端都会影响)。看趋势即可,别当 SLA 用。
一个必须先说清的前提:main 不等于 PyPI
我在本机装了 PyPI 上的 windows-mcp==0.8.8 做实测,撞上一件必须写在前面的怪事:仓库 main 分支的 README 描述的能力,与 PyPI 上那个 0.8.8 的实际行为不一致。
| 项目 | main 分支(README 描述) | PyPI 0.8.8 实测 |
|---|---|---|
serve --control-policy | 有,三档 relaxed/balanced/strict | 不存在(Error: No such option) |
serve --no-control | 有 | 不存在(Error: No such option) |
[control] policy / enabled / idle_timeout_seconds | 支持 | 静默忽略(配置能加载,但值不生效) |
| 鼠标接管阈值默认值 | 500 设备单位 | 120(ControlConfig 默认值) |
| 门控范围 | 按工具分类,感知类工具豁免 | 除 ControlStatus 外全部工具都过门控 |
| 元素预算 / 进程排除 / IA2 回退 | 有 | 有(这部分一致) |
原因不难推:三档门控策略那个 PR(#466)是 2026-10-16 合并进 main 的,而 0.8.8 发布于 2026-10-10。README 跟着 main 一起更新了,但包没重发。
这不是"项目有 bug",而是源码仓库与发行包处于不同状态——如果一个读者只照 README 配置,他会写出一个语法正确但完全不生效的 config.toml(我实测过:写了 policy = "strict" 和 idle_timeout_seconds = 3.0,load_config 正常返回,两个字段被直接丢弃,不报错、不警告)。
所以本文的写法是:涉及门控策略的部分标注为"main 分支 / 未发布",涉及实际可安装行为的部分以 0.8.8 实测为准。 这个区分很重要,否则你会照着未来版本的功能去配一个当前版本的包。
二、实测:装一遍,看它到底什么样
光读 README 写不出可信的文章,所以我在本机装了它。过程本身就有信息量:
python -m venv wmcp_venv
./wmcp_venv/Scripts/python.exe -m pip install windows-mcp
# 官方源 11 分钟无进展,中止
# 换清华镜像:
./wmcp_venv/Scripts/python.exe -m pip install \
--index-url https://pypi.tuna.tsinghua.edu.cn/simple windows-mcp
# Downloading windows_mcp-0.8.8-py3-none-any.whl (257 kB)
# Successfully installed windows-mcp-0.8.8
它只有 257 KB。一个能操作整台 Windows 的 MCP server,轮子比一张截图还小——因为重活全压在 comtypes、pywin32、fastmcp 这些上游依赖上。
2.1 CLI 就四个命令
Usage: windows-mcp [OPTIONS] COMMAND [ARGS]...
Windows-MCP: MCP server for Windows desktop automation.
Commands:
auth Generate an auth key (and optionally TLS certs) and save to...
install Install windows-mcp as a scheduled task that starts at login.
serve
uninstall Remove the windows-mcp scheduled task and stop the...
auth / install / serve / uninstall——没有 doctor、没有 --version、没有配置校验子命令。设计上够用,运维上偏薄:配置写错了没有 lint 能查,只能靠启动日志。
serve 的选项我逐个看过,有两处跟 README 对不上:
$ windows-mcp serve --no-control
Error: No such option '--no-control'.
$ windows-mcp serve --control-policy strict
Error: No such option '--control-policy'.
--help 里也没有这两项。这就是前面说的"仓库超前于发行包"的具体表现。
2.2 21 个工具,实测确认
直接调 FastMCP 的工具注册表,拿到真实列表:
from windows_mcp.__main__ import _build_mcp
m = _build_mcp()
tools = await m.list_tools()
COUNT: 21
['App', 'Click', 'Clipboard', 'ControlStatus', 'DisplayInventory',
'FileSystem', 'Move', 'MultiEdit', 'MultiSelect', 'Notification',
'PowerShell', 'Process', 'Registry', 'Scrape', 'Screenshot', 'Scroll',
'Shortcut', 'Snapshot', 'Type', 'Wait', 'WaitFor']
21 个,跟 CLAUDE.md 声明的数字一致。 顺带把 Snapshot / Screenshot 的真实参数也拉了出来,发现两处容易踩的坑:
| 坑 | 说明 |
|---|---|
参数名是 width_reference_line(单数) | README 正文里写的是 width_reference_lines(复数),照着写会报 unexpected keyword |
Screenshot 也支持 use_annotation 和参考线 | 一般人以为只有 Snapshot 能标注,其实截图也能 |
2.3 一个静默失效的配置
我写了一份刻意混入未发布字段的配置:
[control]
enabled = true
policy = "strict"
idle_timeout_seconds = 3.0
mouse_takeover_units = 300
加载结果:
control: ControlConfig(mouse_takeover_units=300, mouse_takeover_pixels=120)
mouse_takeover_units = 300 生效了,policy 和 idle_timeout_seconds 静默消失——不报错、不警告、不提示 unknown key。这类行为在配置系统里是最伤人的一种:用户以为自己配了,实际什么都没发生,等到线上发现门控不严,已经很难回溯到是这里。
能带走的结论:用这类工具前,先 pip show windows-mcp 确认版本,再对着该 tag 的 README 配,别对着 main 的 README 配。
三、真问题:视觉猜坐标走不通
要理解这个项目的设计选择,得先看清「截图 + 视觉模型」方案的三笔账:
| 成本 | 具体表现 |
|---|---|
| Token | 一张 1920×1080 截图编码后动辄几十万 token,每步都要重发 |
| 精度 | 模型输出坐标有像素级偏差,密集 UI 上点偏是常态,且难以自纠 |
| 延迟 | 视觉推理本身就慢,叠加多轮纠错后单任务动辄几十秒 |
| 适配 | 换个模型、换个分辨率,坐标体系就可能漂移,得重新调 prompt |
Windows-MCP 的答案是把「感知」从像素层抬到语义层:
关键在于坐标从哪来:传统方案里坐标是模型"猜"的,Windows-MCP 里坐标是 Windows 自己报的——UIA 的 BoundingRectangle 属性给出每个元素的精确屏幕矩形,中心点直接算出来。模型只需要做一件事:在列表里挑对那个元素。这是分类问题,不是回归问题,难度低一个量级。
顺带解决的是「视觉模型不可用」场景:本地跑 7B 模型、或者用纯文本模型(DeepSeek、Qwen 之类),一样能干活。README 把这条写成卖点「Use Any LLM (Vision Optional)」,不是营销话术——它的架构里确实没有视觉模型的硬依赖,use_vision 只是个可选参数。
四、分层架构:从 __main__ 到 uia
CLAUDE.md 里给了官方分层说明,我按文件树核对了一遍,实际结构比 README 描述的更细:
三层职责分得很干净:
工具层(tools/) 每个模块对应一组工具,函数体都很薄——归一化参数、调用 service、返回。所有横切关注点(遥测)由一个 @with_analytics 装饰器统一处理,不在每个工具里重复。
桌面服务层(desktop/ + tree/) 是真正干活的地方。desktop/service.py 管窗口生命周期、截图、键鼠注入;tree/service.py 管无障碍树;两者通过元素发现逻辑耦合——点一个元素之前,得先在树里找到它。
UIA 封装层(uia/) 把 COM 那套东西包起来:core.py 是主自动化对象,controls.py 是控件类型特化,patterns.py 包 UIA 的 Pattern(Invoke、Value、Scroll、Text),enums.py 是 COM 枚举,events.py 管事件订阅。这层用 comtypes 而不是 pywin32 的 UIA 封装,原因是 comtypes 能动态生成接口定义,对 UIA 这种接口庞大的 COM API 更省事。
还有个不在 README 主结构里、但确实存在的模块:vdm/core.py。它用 COM 接口 IVirtualDesktopManager(CLSID {aa509086-5ca9-4c25-8f95-589d3c07b48a})判断某个窗口属于哪个虚拟桌面。这件事看着小,实际上决定了 Snapshot 会不会把你在另一个虚拟桌面上的窗口也扫进来——如果扫了,元素 id 就全乱了。
五、两条感知路径:Screenshot 与 Snapshot
新手最容易踩的坑是把这两个搞混。它们的取舍非常明确:
Screenshot | Snapshot | |
|---|---|---|
| 抓什么 | 图像 + 光标位置 + 窗口列表 | 图像 + 完整可交互元素树 + 可滚动区域 |
| 走 UI 树吗 | 不走(明确跳过) | 走,且是主路径 |
| 耗时 | 快 | 慢(取决于目标应用的无障碍质量) |
| Token | 高(一张图) | 中(文本树,但可能很长) |
| 适用 | “现在屏幕上大概是什么样” | “我要精确点第 N 个元素” |
| 参数(实测) | use_annotation、width_reference_line、height_reference_line、display、region | use_vision、use_dom、use_annotation、use_ui_tree、width_reference_line、height_reference_line、display、region |
工具描述里写得很直白:Screenshot 应该是你主要需要视觉上下文时的默认第一次调用。这句话暗含了一个设计判断——大多数时候 agent 并不需要完整元素树,看一眼截图就够决定下一步了,抓树反而是浪费。
Snapshot 的参数堆得比较满,几个值得单独说:
region=[left, top, right, bottom](虚拟桌面像素坐标):只抓一块矩形。当你已经知道要看哪里时,这是最省 token 的选项。文档明确写了region优先于display,且越界会直接报错而不是静默抓别的地方——这个"报错而不是猜"的选择很重要,静默失败会让 agent 在错误的上下文里继续推理。use_annotation(默认True):在检测到的元素上画彩色矩形框。开着它,模型能同时看到图像和标注框,视觉与语义对齐;关掉得到干净截图。width_reference_line/height_reference_line:叠加网格参考线,帮助模型做空间推理。注意参数名是单数line,不是lines——这种地方照着自己直觉写就会失败。use_ui_tree=False:退化成"只要截图不要树"的快速模式。use_vision:把截图一并塞进返回结果。默认False,因为纯文本模型用不上,白白烧 token。
Screenshot 还有个不太显眼但很实用的设计:抓完图会在捕获区域画一圈橙红色发光边框做视觉确认,然后这个边框是在捕获之后画在透明置顶窗口上的,所以永远不会出现在截图里。可以用 WINDOWS_MCP_DISABLE_FLASH=1 关掉。这个细节透露出作者对"人机共用桌面"场景的考虑——AI 抓图这件事本身,应该让人看得见。
六、树抓取的三个工程约束:预算、排除、线程
无障碍树听起来很美,真跑起来会遇到三个硬问题。这个项目为每个问题都留了专门的机制,这是我判断它"工程上认真"的主要依据。
5.1 元素预算:tree/budget.py
有些应用会通过 UIA 暴露巨大的扁平列表或表格——比如一个没加筛选的库存视图,几千行。遍历并序列化每一行会让目标应用卡死好几分钟,返回的响应也可能大到客户端处理不了。
TreeElementBudget 的解法很朴素但很对:
DEFAULT_MAX_TREE_ELEMENTS = 500
- 每次抓取新建一个预算对象(
get_state里self.element_budget = TreeElementBudget(...)),不跨次累积 try_consume()返回False时调用方停止递归,并标记truncated = Truecount硬封顶在limit——单次传入一个超大的amount也推不过去- 结果里明确标注"已截断",而不是假装抓全了返回一个残缺的树
环境变量 WINDOWS_MCP_MAX_TREE_ELEMENTS 可调(默认 500),非法值/非正整数会警告并回落到默认值。这个"截断并声明"的设计比"抓不完就超时失败"好得多:agent 拿到一个明确标注"我只看到前 500 个元素"的树,可以决定先筛选再抓;拿一个超时报错,它只能瞎猜。
5.2 进程排除:WINDOWS_MCP_EXCLUDE_PROCESSES
tree/service.py 里有个函数叫 _window_process_name,注释写得很克制:
Resolves HWND -> PID -> process name using Win32 and psutil only, never UI Automation: an excluded window’s accessibility provider must not be entered just to decide whether to skip it.
这句话是整个机制的关键。如果要判断一个窗口该不该跳过,而去调用 UIA 查它的进程名,那你就已经进入了那个可能有问题的 accessibility provider——问题没解决,反而先踩了坑。所以它走的是 win32process.GetWindowThreadProcessId + psutil.Process(pid).name(),纯 Win32 路径,不碰 UIA。
配套的使用方式:
WINDOWS_MCP_EXCLUDE_PROCESSES=Code.exe,Cursor.exe
匹配规则是大小写不敏感、精确匹配、不支持通配符和正则,逗号分隔,空白会被 trim,空项忽略。文档明确说这是 mitigation 不是完整修复(针对 issue #383)——它只跳过你点名的进程,没点名的病态窗口照样能卡住抓取。这种"承认自己只解决了一部分"的表述,比"彻底解决"可信。
5.3 线程亲和:UIA 的 COM 单线程陷阱
UIA 是 COM API,有线程套间(apartment)要求。在错误的线程上调 UIA,轻则报错,重则让整个进程挂住。仓库的提交历史里能看到这条线的演进:
| PR | 内容 |
|---|---|
| #449 | Run UIA-bound tools on the event-loop thread to preserve COM affinity |
| #451 | Run UIA-affine tools on a dedicated worker thread |
两次改动方向相反,说明第一次尝试(放事件循环线程)在实践中不够——把可能阻塞几秒的 UIA 调用塞进事件循环,会拖住所有并发请求。最终方案是给 UIA 操作单独开一个 worker 线程,用 ThreadPoolExecutor 做多窗口并行遍历(THREAD_MAX_RETRIES=3 做重试)。
代价是文档里那句提醒:一个长的 WaitFor 会阻塞其他 UIA 工作,因为它们共享同一个 worker。这是个诚实的权衡——COM 亲和性没法既要又要。
七、21 个工具的能力矩阵
CLAUDE.md 里按组列出了全部 21 个工具,我按"是否需要桌面所有权"重新分了一次组,因为这直接对应后面的门控策略:
几个设计上值得单独点出来的:
Click 走的是 UIA 坐标系,不是原始屏幕像素。CLAUDE.md 里写明:“Mouse/keyboard input uses UIA (same coordinate space as BoundingRectangle; no DPI mismatch)"。这一条解决了 Windows 高分屏最常见的坑——150% 缩放下,原始 Win32 坐标和 UIA 坐标会差一个缩放因子,混用就点偏。
Type 必须绑定元素,不是"往当前焦点打字”。它需要知道目标元素,因为要处理"是否先清空原内容"这类逻辑。这也解释了它的限制:它面向文本框,不适合在 IDE 里写代码——会把整段程序一次性灌进文件,而不是逐行编辑。作者把这条明确写进 Limitations,还算清醒。
Move 支持确定性拖拽:drag=True + from_loc=[x, y],在一次工具调用里完成"在起点按下 → 移动到 loc → 松开"。如果只给 drag=True 不给 from_loc,起点就是当前光标位置——那就不确定了。duration 参数加中间帧,用于需要触发 hover 或拖拽预览的场景。
WaitFor 比 Wait 值钱得多。Wait 是死等固定时长,WaitFor 是在一次调用内轮询直到文本/活动窗口/元素/焦点元素出现。前者是赌博,后者是同步原语。异步 UI 场景下用错这个,整个任务的成功率会差一个档次。
Scrape 内置 SSRF 防护,封私网 IP、回环、link-local、URL 内嵌凭据、非 HTTP scheme。最近还加了 Fake-IP DNS 的诊断(#473)——这是针对 Clash/mihomo 那类 Fake-IP 模式做的适配,因为在那类环境下 DNS 解析返回的是假 IP,SSRF 判断会误伤。这个细节说明作者在自己踩过。
Registry 是通过 PowerShell cmdlet 实现的,不是直接调 Win32 注册表 API。CLAUDE.md 里承认了后果:“their latency is dominated by process startup”。所以它慢,一次注册表读可能要几百毫秒。这是个用简单换性能的取舍。
八、DOM 模式:浏览器自动化的特殊通道
use_dom=True 是 Snapshot 的一个模式开关,但它值得单独一节,因为它解决的是一个完全不同的问题。
问题:抓浏览器窗口的无障碍树,你会拿到一大堆浏览器外壳元素——地址栏、书签栏、扩展图标、标签页、开发者工具按钮。真正想操作的网页内容被埋在里面,agent 很容易点错。
解法:DOM 模式只抓网页内容区域,过滤掉浏览器 chrome。实现上靠找 AutomationId == "RootWebArea" 的元素,那是 Chrome/Edge 的约定标记,找到它就以此为根往下遍历。
Firefox 是个例外,tree/ia2.py 的模块注释把原因写得很清楚:
Firefox exposes its browser chrome via UIA but its web DOM only via MSAA / IAccessible2. The UIA traversal looks for an element with
AutomationId == "RootWebArea"(the Chrome / Edge convention) — Firefox has no such marker, so DOM extraction is skipped entirely.
所以 Firefox 走的是完全独立的路径:用 AccessibleObjectFromWindow(oleacc.dll)拿到根 IAccessible,然后走 MSAA/IA2 树遍历,最后把结果转成和 UIA 路径相同的结构(dom_informative_nodes / dom_interactive_nodes / dom_bounding_box)。输出同构、输入双路,这个设计让上层的 Snapshot 不需要知道底下用的是哪套 API。
这条路径的实际价值:如果你要用 agent 做 Web 应用的 E2E 测试,DOM 模式比纯视觉方案稳定得多——网页元素有稳定的文本和角色,不像截图那样受渲染影响。
九、桌面租约:当 AI 和你抢同一只鼠标
这是整个项目里我最感兴趣的部分,也是它跟普通自动化库最本质的区别。
普通自动化库的假设:机器是我的,我独占。Windows-MCP 的假设:人也在用这台机器,随时可能回来。
于是它引入了一个「桌面所有权租约」(desktop lease)的概念。逻辑在 desktop/control.py 的 ControlCoordinator 里,核心状态机:
8.1 门控范围:0.8.8 是"全量",main 才分级
先说已发布的 0.8.8:门控是全量的。ControlToolGate.on_call_tool 里只有一个例外:
if name == "ControlStatus":
return await call_next(context)
除此之外所有工具都要先拿租约。也就是说,在 0.8.8 上,哪怕你只是想读一个文件、抓一张截图,只要 AI 当前不持有桌面,调用就会被 USER_CONTROL 挡回去。这在实际使用里很烦——用户在看屏幕,agent 连"看一眼现在什么情况"都做不到。
main 分支(未发布)改成按工具分级,逻辑在 tools/control_policy.py:
| 策略 | 需要所有权的操作 |
|---|---|
relaxed(A,默认) | 直接输入:Click、Type、Scroll、Move、Shortcut、MultiSelect、MultiEdit;App 的 switch 和 resize |
balanced(B) | A 的全部,加上 PowerShell、Notification、App 的 launch/launch_executable、Process 的 kill、Clipboard 的 set |
strict(C) | 除 ControlStatus 之外全部(等价于 0.8.8 的现有行为) |
在 A 和 B 下完全绕过所有权的工具:FileSystem、Registry、Snapshot、Screenshot、Scrape、Wait、WaitFor、DisplayInventory、Process 的 list、Clipboard 的 get。
这个分类的依据是桌面干扰性,不是危险性。代码注释把这点写死了:
# Categories describe desktop interference, not whether an operation is destructive.
这个区分很重要。“危险"和"抢鼠标"是两个正交的维度。删文件很危险但不抢鼠标;点一下几乎无害但会抢焦点。混淆这两件事,就会得到一套既烦人又不安全的分级。
requires_control() 的兜底逻辑也值得看:
category = "direct" # Unknown tools/modes must not silently bypass ownership.
未知的工具或模式默认走最严的那一档。新加工具如果忘了在表里登记,不会悄悄绕过门控,而是会被保守地当成直接输入。配套的是"注册覆盖率测试”——漏登记会被测试发现。
8.2 空闲超时的两个坑(main 分支)
配置在 ~/.windows-mcp/config.toml:
[control]
enabled = true
policy = "relaxed"
idle_timeout_seconds = 10.0
文档里有两句必须读清楚的话:
- “zero does not disable control” —— 写 0 不是关闭,0 会被拒绝(只接受有限正数,可以是小数)。要关得用
enabled = false或--no-control。 - “shortening it does not allow gated calls while you keep using the computer” —— 空闲计时器随物理输入重置。你把超时从 10 秒改成 3 秒,并不能让你一边打字一边让 AI 操作;只要你在动,计时器就一直归零。
⚠️ 这条对当前发布版有个陷阱:0.8.8 的 ControlConfig 只有两个字段——mouse_takeover_units 和 mouse_takeover_pixels,没有 policy、enabled、idle_timeout_seconds。我实测写入这三项,load_config 正常返回、不报错、不警告,值全部丢失:
control: ControlConfig(mouse_takeover_units=300, mouse_takeover_pixels=120)
输入里的 policy 和 idle_timeout_seconds 就这么消失了。 这类"配置写了但不生效"的问题最难查——你没有报错可看,只能靠观察行为发现不对劲。
更实际的问题是:0.8.8 上没有任何关闭控制的途径。--no-control 不存在,[control] enabled = false 不被识别,WINDOWS_MCP_CONTROL 环境变量也没实现。门控是硬编码常开的。如果你需要一个"不要管我,随便操作"的环境(比如全自动回归测试的虚拟机),当前发布版给不了。
还有个操作细节:改完配置必须重启 server,没有热重载,也没有让 AI 自己改配置的工具。这是有意为之——如果 AI 能改自己的门控策略,那门控就没意义了。
十、物理输入接管与输入账本
门控解决的是"该不该让 AI 动手",接管解决的是"AI 动手时人回来了怎么办"。
9.1 双通道接管
| 通道 | 触发条件 | 细节 |
|---|---|---|
| 鼠标 | 累计相对原始位移达阈值 | 0.8.8:120;main:500(可在 control.mouse_takeover_units 调 40–2000) |
| 键盘 | Ctrl+Alt+Shift+Backspace | 给不提供可识别相对原始输入的设备(部分触摸板、远程输入设备)留的后路 |
⚠️ 阈值在 0.8.8 和 main 上差了 4 倍多。120 设备单位大约是鼠标快速甩一下的量,实际体感是"很容易误触发";500 才更接近"蓄意夺回"。如果你在 0.8.8 上觉得 AI 稍微一动就被踢出控制,这是原因。
鼠标这条的实现有几个精确到"设备单位"的细节:
- 是设备单位,不是屏幕像素。
control_hooks.py里累加的是_read_raw_mouse()读到的原始相对位移,绕过指针加速和 DPI 缩放。 - 累加方式在 0.8.8 和
main上不同。0.8.8 是math.hypot(x, y)算净位移——你往右移 300 再往左移 300,累计净位移回到原点,永远触发不了。main改成self._raw_distance += math.hypot(dx, dy),逐次累加路径长度,来回移动也计入:
if (dx or dy) and stamp >= self._motion_since:
if device != self._raw_device or stamp - self._last_move >= 0.5:
self._raw_distance = 0.0
self._last_move = stamp
self._raw_distance += math.hypot(dx, dy)
if self._raw_distance >= self.mouse_takeover_units:
这个改动修的是一个真实的坑:净位移算法下,如果你把鼠标"来回抖",AI 控制永远夺不回来。
- 0.5 秒无移动就分段清零(
main):所以你不能慢慢磨够阈值——得在连续动作里达到。 - 切换设备或开启新的控制周期也会清零。
关于阈值的语义:README 说这是"deliberate takeover"(蓄意接管)的门槛,不是"任何移动"。但 120 这个默认值跟"蓄意"这个词是不匹配的——它更像"检测到明显移动"。这一点上 main 的 500 更名副其实。
9.2 输入账本:control_ledger.py
这是我认为整个项目里工程质量最高的一段代码。问题场景很尖锐:
AI 按下了 Ctrl 键,还没松开,用户接管了。这时 Ctrl 处于物理按下状态。如果 AI 直接发一个 Ctrl-up,会跟用户真实按下的 Ctrl-up 冲突——用户按的键被"偷"了。
InputLedger 的解法:
def press(self, name, send_down, send_up, physical_down_delivered):
with self._lock:
if self._blocked:
raise InputUnavailable("AI input is unavailable")
if name in self._holds:
raise RuntimeError(f"AI input already held: {name}")
self._holds[name] = (send_up, physical_down_delivered)
try:
# Keep the lock through injection so a watchdog cannot send
# up before the matching down has actually reached Windows.
send_down()
except BaseException:
try:
self.release(name)
except Exception:
pass # Keep the entry for shutdown's second attempt.
raise
三个关键决策:
- 注入前先记账。
self._holds[name] = ...在send_down()之前。注释解释了原因:发送例程可能注入成功后抛异常,如果先注入后记账,这个"已按下"的状态就丢了,之后永远不会被释放——Ctrl 会卡在按下状态。 - 持锁穿越注入。不能"先放锁再注入",否则看门狗可能在 down 到达 Windows 之前就发出 up,顺序错乱。
release()检查物理键是否已送达:
if not physical_down_delivered():
send_up()
如果用户在接管后又按了同一个键,那个键的 up 事件会由用户自己产生,此时 AI 再补一个 up 就会把用户的按键状态搞乱。所以只在"物理按键没送达"时才补发 up。
这段代码处理的是一类很容易被忽略的 bug:注入式输入和物理输入共享同一个键盘状态机。任何做 RPA 或远程控制的项目都会撞上,但大多数选择忽略,代价是偶发的"按键卡住"。这里是正面解决。
9.3 但接管不是安全边界
README 有一段话说得很不客气,我原样转述其要点:
- 接管是 best-effort,不是安全边界,也不是"独占控制"的保证
- Windows 可能静默摘除低级键盘钩子且不通知进程;周期性重建钩子能缩小但不能消除这个窗口
- 已经被触发的外部命令(比如
PowerShell、App启动)不会被撤销,门控不杀进程、不回滚已完成的副作用 - 钩子故障时注入的按键是异步释放的,所以物理输入可能短暂地与仍被 AI 按住的键重叠
一个项目主动把自己的安全机制说成这样,不是自曝其短,是划清责任边界。把 best-effort 说成 guarantee 才是问题。用户读到这段,就知道不能靠它做无人值守的高危操作。
十一、安全面:默认关闭的那些开关
这个工具的默认姿态是"本地、单用户、不加防护",这从设计上是合理的——stdio 传输本来就不出本机。但一旦你把它开到网络上,风险等级就完全不同了。它的安全选项设计得比较讲究,核心思路是默认最严、显式放开。
10.1 CORS 默认不发光任何头
By default, no CORS headers are emitted.
配合 TrustedHostMiddleware 做 Host 头校验(DNS rebinding 防护),效果是:即使 server 跑在 localhost:8000,任意网页也打不到它。因为浏览器同源策略会拦掉跨域请求,而 server 不返回 CORS 头,浏览器就不会放行。
这条对应一个已修复的漏洞 GHSA-vrxg-gm77-7q5g(2026-05-14)。修复前的代码发了 Access-Control-Allow-Origin: *,意味着你随便打开一个恶意网页,页面里的 JS 就能连上你本机的 MCP server 然后为所欲为。这是一个把本地工具变成 RCE 通道的经典模式。
需要浏览器客户端直连时,必须显式白名单:
windows-mcp serve --cors-origins "https://my-client.example.com"
只有列表里的源能收到 Access-Control-Allow-Origin,其余跨域请求由浏览器拦掉。
10.2 OAuth 2.0 + PKCE 的克制实现
仅 streamable-http 支持,暴露四个端点:
| 端点 | 作用 |
|---|---|
GET /.well-known/oauth-authorization-server | RFC 8414 服务器元数据 |
GET /oauth/authorize | Authorization Code 流程,强制 S256 |
POST /oauth/token | 换 token,需要 client secret |
POST /oauth/register | 禁用 |
三个克制之处:
- 强制 S256,不接受
plain。PKCE 用 plain 等于没用。 - 禁用动态注册。client 必须预先配置。这砍掉了 OAuth 生态里最容易被滥用的环节——开放式注册等于任何人都能给自己发一个 client。
- redirect URI 只允许 loopback。防止 token 被导到外部地址。
auth_key 和 OAuth 可以共存,两者都被接受为有效的 Bearer token。这个设计方便迁移——先上静态 key,逐步切到 OAuth。
10.3 auth 辅助命令
手工配一套 auth + TLS 挺烦,所以有个一键命令:
windows-mcp auth --transport streamable-http --host 0.0.0.0 --port 8000 --with-tls
它会生成 32 字节的 secrets.token_urlsafe 作为 key,写进 ~/.windows-mcp/config.toml,用 OpenSSL 生成自签证书(RSA 4096,365 天),最后打印对应传输方式的客户端配置示例。省掉了最容易出错的几个环节。
10.4 配置优先级
四层,从高到低:
这个顺序符合惯例,但有个细节值得注意:证书路径相对于 ~/.windows-mcp/ 解析,不是当前工作目录。所以配置文件里写 ssl_certfile = "cert.pem",实际指向 ~/.windows-mcp/cert.pem,跟你从哪个目录启动无关。这类"相对谁的路径"问题,是配置系统里最容易埋坑的地方。
10.5 遥测
默认开启 PostHog 匿名遥测,文档声称不收集个人信息、工具参数、输出。关闭方式:
"env": { "ANONYMIZED_TELEMETRY": "false" }
POSTHOG_API_KEY 设成空字符串可以完全跳过 PostHog 客户端初始化,POSTHOG_HOST 可以指向自建实例。对一个能操作整个系统的工具来说,“默认开遥测"这个选择会让一部分企业用户直接放弃——但至少开关给得很完整。
十二、发布工程:四处版本号与一道守卫
这部分跟功能无关,但能看出团队的工程成熟度。
版本号在四个地方声明,分别供给不同渠道:
| 文件 | 消费方 |
|---|---|
pyproject.toml | PyPI 包 |
uv.lock | 锁定的工作区成员 |
manifest.json | Claude Desktop 扩展 |
server.json 的 packages[] | MCP Registry |
scripts/check_versions.py 的模块注释把动机讲成了一个事故复盘:
At the v0.8.5 release
manifest.jsonandserver.json’s package entry were both still on 0.8.1, two releases behindpyproject.toml, because the 0.8.2 bump touched onlypyproject.toml. Nothing failed — the extension simply kept advertising a version that was no longer what shipped.
这正是典型的静默漂移:没有任何测试失败,没有任何报警,只是扩展商店里显示的版本号慢慢变成假的。CI 现在在 uv build 之前强制跑这个脚本,不一致就阻断发布。
还有个更细的判断:server.json 的顶层 version 故意不检查。注释解释:那个字段标识的是 registry 条目本身,走独立的 1.x 线,强行对齐等于把 registry 降级。只检查 packages[].version——那个才必须跟 PyPI 一致。
“检查该检查的,放过不该检查的”,这个取舍比"全都对齐"更难做对。
十三、它自己承认的边界
README 的 Limitations 只有三条,但每条都很实在:
| 限制 | 原因 | 状态 |
|---|---|---|
| 无法选中段落内的局部文本 | 依赖 a11y tree,树只到段/节点粒度 | 开发中 |
Type 不适合在 IDE 写代码 | 把整段程序一次性键入文件,不逐行编辑 | 开发中 |
| 不能用来打电子游戏 | 实时性 + 反作弊 + 输入模式都不匹配 | 无计划 |
第一条影响比看起来大:任何"选中这句话然后改写"的需求都做不了。文本编辑器、文档工具、代码审查场景都会撞上。这是从像素层抬到语义层的代价——你换来了确定性,失去了像素级的自由度。
还有几条散落在其他章节、容易被忽略的:
- UIA 共享一个 worker:长的
WaitFor会拖住其他 UIA 操作 Registry通过 PowerShell 实现:延迟由进程启动主导- Playwright 类能力缺失:DOM 模式只做提取,不做导航/等待/断言,不是浏览器测试框架替代品
WINDOWS_MCP_WATCHDOG默认关闭:因为它的 UIA 事件订阅在长时间运行后(跨睡眠/恢复、会话切换)可能导致 server 崩溃(#332)。官方态度是:默认别开,需要调试再开。
还有两条是本文实测才发现的,README 不会告诉你:
main与 PyPI 不同步:当前 0.8.8 缺三档门控、缺--no-control、缺idle_timeout配置。照 README 配会得到一份"看起来对、实际没生效"的配置。- 0.8.8 无法关闭控制门:所有工具都要过租约,除了
ControlStatus。没有环境变量、没有 CLI、没有配置能把它关掉。 - 参数名陷阱:
width_reference_line是单数line,README 正文写作复数lines。这类不一致在快速迭代的项目里很常见,但足以让一次调用直接失败。
十四、什么时候该用它,什么时候不该
| 场景 | 判断 | 理由 |
|---|---|---|
| 把本地 Windows 应用接进 agent 工作流 | ✅ 合适 | 这是它唯一的设计目标,也是最强的地方 |
| 用文本模型做桌面自动化(无视觉) | ✅ 合适 | 21 个工具都不依赖视觉,use_vision 可选 |
| Web 应用 E2E 测试 | ⚠️ 谨慎 | DOM 模式能做元素提取,但没有断言/等待/录制,不如 Playwright |
| 无人值守的高危操作(改注册表、批量删文件) | ❌ 不合适 | 无沙箱、无审计、无回滚,接管只是 best-effort |
| 实时性要求高的交互(游戏、音视频编辑) | ❌ 不合适 | 0.2–0.5s 的动作延迟是数量级不匹配 |
| 需要精确文本选区编辑的任务 | ❌ 暂不合适 | a11y tree 到不了字符粒度 |
| 多用户共享的 Windows Server | ❌ 不合适 | 设计假设是单用户本地桌面 |
关于"全系统权限"这条,CLAUDE.md 写得很直接:
This server has full system access with no sandboxing.
PowerShell,FileSystem,Registry,Process, andAppcan all perform irreversible operations, and there is no audit log or rollback. The recommended deployment target is a VM or Windows Sandbox.
推荐部署目标是虚拟机或 Windows Sandbox——这是项目自己给的定位。如果你打算在生产环境用它,先想清楚这一点。
实用建议:用 --exclude-tools 砍掉用不上的高危工具。
windows-mcp serve --exclude-tools "PowerShell,Registry,Process"
对纯 UI 自动化场景,这三个基本用不到,砍掉之后风险面小很多,而且不影响核心能力。
十五、成熟度评估
| 维度 | 评价 | 依据 |
|---|---|---|
| 功能完整度 | 高 | 21 个工具覆盖感知/输入/系统三层,日常任务够用 |
| 工程严谨度 | 高 | 输入账本、元素预算、版本守卫、进程排除、COM 亲和,都是踩过坑之后的正解 |
| 文档质量 | 高 | README + CLAUDE.md 详尽,代价和限制写在明面上 |
| 测试覆盖 | 中 | 有 tests/ 目录与 CI(windows-latest),但测试依赖 pywin32,只能 Windows 跑 |
| API 稳定性 | 低 | 自我标注 Beta;0.8.x 系列仍在快速迭代,接口有变动 |
| 安全默认值 | 中高 | 本地场景合理;上网络需要显式配置,但配置项给全了 |
| 运维友好度 | 中 | 有 Scheduled Task 自启、日志落盘,但无热重载、无健康检查端点 |
| 社区活跃度 | 高 | 56 位贡献者,775 次提交,dependabot 常态运行,issue 响应及时 |
一句话:这是一个"作者自己天天在用"的项目。判断依据不是 star 数,而是那些只有真正用过才会加的机制——元素预算(被几千行的表格卡过)、进程排除(被 VS Code 的无障碍树卡过)、输入账本(被卡住的 Ctrl 键坑过)、版本守卫(被 0.8.5 的漂移事故坑过)。每一条都对应一次具体的翻车。
十六、结语
回到开头那个判断:它把无障碍树当成了 API。
这个选择的好处是确定性和通用性——不训练模型、不做视觉推理、任意 LLM 都能用,动作延迟压到 0.2–0.5 秒。代价是它继承了 UIA 的所有局限:选不了字符、读不到自绘 UI、被病态无障碍实现拖死、Firefox 得走另一套 API。
但我觉得它真正值得学的地方不在技术选型,而在对人机共存的正面处理。
绝大多数自动化工具假设"机器归程序所有”。Windows-MCP 承认"人在旁边,随时会回来",然后做了三件事:给接管留通道(鼠标位移和热键双路)、给按键状态记账(不错乱、不卡键)、给 AI 的占用加可视提示(边缘光晕 + 截图闪烁边框)。main 分支上还在做第四件——按工具分级,让读文件、抓截图这类不抢焦点的操作不必等租约。
而且它诚实地说明这套机制只是 best-effort,不是安全边界。
这条路走得并不完美。0.8.8 的门控是全量的,粒度过粗;阈值 120 偏敏感;配置里写了不生效的键也不报错。但从设计方向上看,它把"人在回路"当成了架构的一部分,而不是事后补的开关——这个起点是对的,剩下的都是打磨。
承认自己做不到什么,比宣称自己什么都能做,更能建立信任。 这条在工程上和在别处一样成立。
免责声明:本文基于 2026-10-18 的仓库快照与 PyPI 包
windows-mcp==0.8.8在本机的实际测试撰写。文中"实测"结论均可复现(命令与代码片段已在正文给出);"main分支"的结论来自 2026-10-18 的源码与 README 交叉核对,该部分功能在 0.8.8 中尚未发布。项目仍在活跃迭代,接口、默认值、安全策略均可能变化,部署前请以仓库最新 README 与 SECURITY.md 为准。未对全部 179 个文件逐一审计;涉及安全的结论不构成安全评估。Windows-MCP 具备完整系统权限且无沙箱,请仅在虚拟机、Windows Sandbox 或可回滚环境中试用。