Windows-MCP:给 AI 一副能操作 Windows 的手——无障碍树、桌面租约与 21 个工具

对 CursorTouch/Windows-MCP 的深度拆解:9.4k star 的 Windows 桌面操控 MCP server。它不训练视觉模型、不做截图猜坐标,而是把 Windows UIAutomation 无障碍树喂给任意 LLM,用可点击元素 id 换取确定性;本文拆它的分层架构、21 个工具的能力矩阵、树抓取预算与快照双路径、桌面控制租约与三种门控策略、物理输入接管与输入账本、SSE/HTTP 传输的安全面(CORS 默认关闭、DNS rebinding 防护、OAuth+PKCE、SSRF 封堵)、四处版本号的发布守卫,以及它明确写进文档的现实约束:全系统权限无沙箱、接管不是安全边界、Type 不能写代码。

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

图:Windows-MCP 封面——无障碍树驱动的桌面操控

目录

  1. 它是什么:一份数据快照
  2. 实测:装一遍,看它到底什么样
  3. 真问题:视觉猜坐标走不通
  4. 分层架构:从 main 到 uia
  5. 两条感知路径:Screenshot 与 Snapshot
  6. 树抓取的三个工程约束:预算、排除、线程
  7. 21 个工具的能力矩阵
  8. DOM 模式:浏览器自动化的特殊通道
  9. 桌面租约:当 AI 和你抢同一只鼠标
  10. 物理输入接管与输入账本
  11. 安全面:默认关闭的那些开关
  12. 发布工程:四处版本号与一道守卫
  13. 它自己承认的边界
  14. 什么时候该用它,什么时候不该
  15. 成熟度评估
  16. 结语

一、它是什么:一份数据快照

一句话:一个跑在本机 Windows 上的 MCP server,把 21 个桌面操作工具暴露给任意支持 MCP 的 LLM 客户端。

数据快照(2026-10-18 通过 GitHub API 与仓库文件核实):

维度数据
仓库CursorTouch/Windows-MCP
定位MCP Server for Computer Use in Windows
Stars / Forks9,387 / 1,013
Open Issues25
首次提交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

新手最容易踩的坑是把这两个搞混。它们的取舍非常明确:

ScreenshotSnapshot
抓什么图像 + 光标位置 + 窗口列表图像 + 完整可交互元素树 + 可滚动区域
走 UI 树吗不走(明确跳过)走,且是主路径
耗时快慢(取决于目标应用的无障碍质量)
Token高(一张图)中(文本树,但可能很长)
适用“现在屏幕上大概是什么样”“我要精确点第 N 个元素”
参数(实测)use_annotation、width_reference_line、height_reference_line、display、regionuse_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 = True
  • count 硬封顶在 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内容
#449Run UIA-bound tools on the event-loop thread to preserve COM affinity
#451Run 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

文档里有两句必须读清楚的话:

  1. “zero does not disable control” —— 写 0 不是关闭,0 会被拒绝(只接受有限正数,可以是小数)。要关得用 enabled = false 或 --no-control。
  2. “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

三个关键决策:

  1. 注入前先记账。self._holds[name] = ... 在 send_down() 之前。注释解释了原因:发送例程可能注入成功后抛异常,如果先注入后记账,这个"已按下"的状态就丢了,之后永远不会被释放——Ctrl 会卡在按下状态。
  2. 持锁穿越注入。不能"先放锁再注入",否则看门狗可能在 down 到达 Windows 之前就发出 up,顺序错乱。
  3. 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-serverRFC 8414 服务器元数据
GET /oauth/authorizeAuthorization 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.tomlPyPI 包
uv.lock锁定的工作区成员
manifest.jsonClaude Desktop 扩展
server.json 的 packages[]MCP Registry

scripts/check_versions.py 的模块注释把动机讲成了一个事故复盘:

At the v0.8.5 release manifest.json and server.json’s package entry were both still on 0.8.1, two releases behind pyproject.toml, because the 0.8.2 bump touched only pyproject.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, and App can 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 或可回滚环境中试用。