utools-service-manager:把Windows服务管理塞进uTools,再交给AI Agent
灿若繁星先生

Windows 上自建服务跑多了,services.msc 就成了那个要用时才想起、打开又嫌烦的工具:想重启个 nginx,先 Win+R,再在三百多个服务里滚到 N 区,点开属性,确认,重启。我做了一个 uTools 插件 utools-service-manager 来省掉这套动作:把 Redis、Consul、nginx 这类常用服务加进白名单,uTools 里敲 svc 就能看实时状态、启停、改启动类型。更进一步,通过 uTools 内置的 MCP,Claude Code 这些 AI Agent 也能直接对它说「重启一下 nginx」「把 Redis 设为禁用」——一句话的事。

项目已开源:GitHub - canyuda/utools-service-manager,MIT 协议,需要 uTools 5+ 和 Windows。

从三个服务说起

起因是公司的一个项目:本地把项目跑起来之前,得先启动 nginx、Consul、Redis 三个服务。开发环境是 Windows,我把它们都注册成了 Windows 服务,管理是统一了,日常操作却麻烦——每次启停都要打开服务列表,在三百多个服务里翻那三个。

我的日常工具箱里常驻 uTools:切换 hosts、nvm 插件管理 Node 版本(公司有些前端项目必须用低版本 Node)、本地文件搜索,高频操作都收在搜索框里。顺手一搜 Windows 服务管理的插件——没有现成的,那就自己写一个。

动手之后翻文档,发现 uTools 还提供了 MCP 能力:插件可以把功能注册成 MCP 工具,暴露给 AI Agent 调用。这意味着 nginx、Consul、Redis 的启停可以直接在 Agent 里说一句「重启 nginx」就完成,人不用离开终端。插件因此从设计上就有两个入口:给人用的搜索框,给 Agent 用的 MCP。

两个入口,一套底层

插件实际上是给同一套服务控制层配了两个入口。

给人用的:uTools 关键词 服务管理 / svc 进入。主界面是白名单服务的卡片列表,每 2 秒自动轮询状态,状态变化时高亮提示。启动、停止、暂停、恢复、重启都是一键操作;启动类型支持自动 / 自动(延迟启动)/ 手动 / 禁用四档,修改前二次确认,「禁用」单独标红——把一个服务禁用之后忘了,排查起来能坑半天。加服务时可以对全机 310+ 个服务做关键字过滤,秒级出结果。

界面长这样:

image

主列表:顶部横幅在 uTools 未以管理员运行时给出引导;状态按运行中 / 已停止 / 已暂停筛选,右侧是启动类型标签和启停按钮。

image

展开详情:服务描述、二进制路径、运行账户一目了然,启动类型下拉直接改,全部控制操作集中在这里。

image

添加服务:全量服务关键字过滤,回车加入列表。

image

深色模式自动跟随 uTools 主题。

给 Agent 用的:通过 uTools 的 MCP Server 暴露 9 个工具,外加一个存储诊断工具。Claude Code / Codex 等支持 MCP 的 Agent 连上后就能调用:

1
2
mcp__utools__restart_service { "name": "nginx" }
mcp__utools__set_service_start_type { "name": "Redis", "startType": "disabled" }

一个方便的细节:插件窗口没开着也没关系,uTools 收到 MCP 调用会自动拉起插件。

Agent 侧的安全设计是四道闸:MCP 总开关默认关闭(插件设置里开);写操作另需「允许 Agent 写操作」子开关,也默认关闭;写操作受系统管理员权限限制,权限不足时返回明确的中文提示;每次工具调用写入审计日志(保留最近 50 条),设置页可查可清。只读工具(列表、搜索、详情)不受子开关限制, Agent 想先看看状态再决定动手,不需要额外授权。

服务控制层:PowerShell 查询 + sc.exe 控制

插件没有引任何系统级的原生模块,服务操作全靠 Windows 自带的两样东西。

查询走 PowerShell 的 CIM。白名单状态是一次单进程批量查询完成的:把服务名数组序列化后嵌进脚本,Get-CimInstance Win32_Service 逐个取,顺手读注册表的 DelayedAutostart 标志来区分「自动」和「自动(延迟启动)」——这个差异 Win32_Service 本身不直接给。结果用 ConvertTo-Json -Compress 一次性吐回。全量列表(添加页用)走另一条精简脚本,不读注册表,保证 310+ 服务的速度。

控制走 sc.exe。这里有两个实测出来的坑,都留在了代码注释里:

一是 sc config 的语法要求 start= auto——等号后面必须有空格。这种反直觉的写法用 argv 数组拼参数恰好能拼对:['config', name, 'start=', startType]。

二是 sc 的输出是 GBK 编码,按 utf8 解码会得到乱码。与其处理编码,干脆不回传 sc 的原始输出,只取退出码,映射成中文文案:5 是权限不足、1051 是被其他服务依赖无法停止、1053 是响应超时、1060 是服务不存在……常见的错误码全量收录在一张表里,用户看到的是「权限不足:请以管理员身份运行 uTools 后重试」,而不是 CreateService FAILED 5:。

管理员探测用的是 net session——只有管理员能成功执行,比读令牌的方法省事,8 秒超时兜底。

重启是最复杂的一个操作,值得单独贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function restartService (name) {
const stop = await control(name, 'stop')
if (!stop.ok && stop.errorCode !== 1062) return stop // 1062 = not running, treat as stopped
const t0 = Date.now()
for (;;) {
await new Promise(r => setTimeout(r, 800))
const st = (await getServicesStatus([name]))[0]
if (!st || !st.exists || st.state === 'Stopped') break
if (Date.now() - t0 > 15000) {
return { ok: false, errorCode: 1053, message: ERR_TEXT[1053] + '(等待停止超时)' }
}
}
return control(name, 'start')
}

坑在「停止」不是同步完成的:sc 停止指令返回 0 只代表命令被接受,服务状态可能还停在 STOP_PENDING。不等完全停止就 start,会得到一个 1056(服务已在运行)或者更诡异的中间态。所以重启的实现是:停止 → 容忍「本来就是停止状态」(错误码 1062)→ 每 800ms 查一次直到真正 Stopped → 再启动,全程 15 秒超时保护。

MCP 工具注册与前置闸门

工具声明在 plugin.json 的 tools 键里,每个工具带完整的 description 和 inputSchema(全部 additionalProperties: false)。描述写得比较啰嗦是故意的:Agent 选工具、填参数全靠这段文字,状态枚举、入参格式、「name 是服务名不是显示名」这类易错点都写进去了。plugin.json 里声明的键和 preload 里 registerTool 的调用必须一一对应。

preload 侧用一个小包装函数统一挂上前置闸门和审计:

1
2
3
4
5
6
7
8
9
10
11
12
13
const wrap = (tool, write, fn) => {
u.registerTool(tool, async (params) => {
let result
try {
const g = guard(write) // MCP master switch + write sub-switch
result = g || await fn(params || {})
} catch (e) {
result = { ok: false, errorCode: 'ERROR', message: String((e && e.message) || e) }
}
pushAudit(tool, params, result) // audit log, keep latest 50
return result
})
}

每个工具声明自己是不是写操作(write),guard 按需检查两级开关,任何异常都收口成 {ok: false, message} 返回给 Agent——MCP 工具抛异常对 Agent 的体验很差,统一的错误结构让它能读懂失败原因并向用户复述。

还有一个时序坑:uTools 的 API 桥注入时机可能晚于 preload 顶层执行,utools 拿不到就注册不了工具。解法是所有 uTools API 都走惰性获取(不缓存顶层引用),registerTool 注册失败时延迟重试(最多 20 次 × 250ms),桥什么时候到位什么时候注册。

持久化:双后端、四层兜底

uTools 提供两套本地存储:dbStorage(键值库)和 db(文档库)。插件在 preload 侧把两个都用了:写的时候两边尽力写,每边写入后立刻读回验证(JSON.stringify 比对);读的时候按序取,谁有数据用谁。文档库那边还处理了 _rev 冲突——put 失败时重读当前版本、合并重试。

渲染进程侧在这之上又加了两层:preload 桥(svcApi 转发,preload 侧自己还有双后端)和 localStorage(纯浏览器环境兜底)。所以白名单、设置、审计日志的读写实际是四层后端依次兜底,任何一层失败都把具体错误带回给调用方展示。

这个设计的起因是开发中真实踩过一次 dbStorage 写入后读不回来的问题,当时没有第二份数据,白名单直接丢了。存储层是插件的命根子——丢了白名单,用户加的服务全没了——所以宁可过度设计。事后还加了个 storage_debug 工具,把两个后端里所有键的原始内容 dump 出来,专门用于排查这类问题。

开发体验上的几个设计

  • 浏览器演示模式:检测不到 uTools 环境时,backend.js 自动切到 demo 后端(内存数据 + localStorage),接口签名与真实后端完全对齐。前端开发调试不用碰 Windows 服务,也不用开 uTools,npm run dev 起来就是完整界面。
  • preload 纯 CJS:uTools 的 preload 跑在 CommonJS 环境(public/preload/package.json 里显式 type=commonjs),而渲染进程是 Vite + ES Module。两边通过 window.svcApi 一套固定签名隔离,Vite 只构建渲染层,preload 原样拷贝。
  • 原型先行:prototype/ 里有可交互的 HTML 原型,浏览器打开后 ?variant=A|B|C 切换三种布局方案。先在原型里把交互磨平,再写进 Vue 组件,避免在工程里来回改布局。
  • 运行时依赖只有 Vue 一个,构建就是 vite build,产物 dist/ 直接作为 uTools 开发者工具的项目目录。

现状与安装使用

插件刚完成开发,正在 uTools 插件市场的上架审核中,通过后普通用户在市场搜「服务管理」即可安装。想现在就体验,两条路:用 uTools 开发者工具直接跑,或自行构建 .upx 离线包安装。

开发者步骤:克隆仓库进 Windows服务/,npm install && npm run build,然后在 uTools 开发者工具里新建项目并指向 dist/,应用 ID 和插件密钥需要先创建应用获取。日常改前端用 npm run dev 走 5173 热更新;改 public/**(preload、plugin.json)后需要重新 build 并刷新项目。

一个前置条件要知道:修改启动类型、停止服务这类写操作需要以管理员身份运行 uTools,仅查看状态不需要。非管理员打开插件时会有横幅引导,只读功能完全可用。

小结

这个插件的出发点很小:少打开一次 services.msc。做完发现真正值得写的是两类问题——和 Windows 系统工具打交道时的兼容性细节(sc 的空格语法、GBK 输出、CIM 查询、停止是异步的),和 uTools 平台的工程约束(双存储后端、API 桥注入时序、preload 的 CJS 环境)。AI Agent 入口倒是最省事的部分:uTools 把 MCP Server 建好了,插件只需要把工具描述写清楚、把权限闸门立好,Agent 接进来就能用。

设计文档、uTools API 笔记都在仓库 docs/ 里,欢迎提 issue 和 PR。