Merge pull request #120 from Mangome/feat/mcp-agent

新增 AI Agent 的 MCP 工具,支持增删改查快捷命令
This commit is contained in:
fofolee authored and GitHub committed 2026-10-13 20:18:34 +08:00
commit 74ff283614
11 files changed
+1058 -3

No files matched your search

+15
View File
@@ -36,6 +36,7 @@ quasar build
- 快速收藏文件、网址、插件别名,通过面板视图,实现类似软件启动器、网页搜藏夹、插件面板等功能
- 定时运行命令
- 提供后台服务,将插件内部和外部环境打通
- 把命令的增删改查作为 MCP 工具开放给 AI Agent(需要 uTools 支持本地 MCP 服务)
# 三、配置参数
@@ -88,6 +89,20 @@ quasar build
- 发送到系统通知
- 在终端中显示
## 「 AI Agent 」
把快捷命令的增删改查开放给外部 AI Agent(Claude Code、Codex、Cursor 等),让它帮你批量写命令、改命令。
使用步骤:
1. 在 uTools 的 `设置 → MCP 服务` 里开启本地 MCP 服务,并用它的一键写入功能把 uTools 加进 AI Agent 的 MCP 配置
2. 打开任意命令的编辑器,点右侧 AI 助手里的齿轮图标,在 `API配置` 弹窗底部按需打开 AI Agent 的 `读取` / `写入` / `运行` 开关
3. 让 Agent 调用 `qc_get_schema` 了解命令格式后再动手
提供的工具:`qc_list_commands`、`qc_get_command`、`qc_list_tags`、`qc_get_schema`、`qc_create_command`、`qc_update_command`、`qc_delete_command`、`qc_set_enabled`、`qc_run_command`。
插件自带的默认命令(code 以 `default_` 开头)不能被读写。
# 四、可视化编排功能概览
## 音频操作
+1 -1
View File
@@ -8,7 +8,7 @@
"scripts": {
"lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,css,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0"
"test": "node test/commandStore.test.js"
},
"dependencies": {
"@quasar/extras": "^1.14.0",
+103
View File
@@ -0,0 +1,103 @@
/**
* 快捷命令的数据结构说明,qc_get_schema 直接返回这段文本给 AI Agent
*/
module.exports = `# 快捷命令数据结构
一条命令就是一个 JSON 对象,字段如下:
{
"program": "quickcommand",
"cmd": "脚本内容",
"features": {
"code": "key_1a2b3c4d",
"explain": "命令名称(uTools 搜索框里显示的名字,不能为空)",
"cmds": ["触发关键词", { "type": "regex", "label": "名称", "match": "/^https?:\\\\/\\\\//i" }],
"platform": ["win32", "darwin", "linux"],
"mainPush": false,
"icon": ""
},
"output": "text",
"tags": ["日常"]
}
## program(执行方式)
- quickcommand:JavaScript,跑在插件沙箱里,可以用 quickcommand.* / utools.* / fs / require 等,输出的对象里带 code 字段时会把 code 当结果输出
- quickcomposer:可视化编排,内容在 flows 数组里(见下文)
- html:HTML 页面
- shell / applescript / cmd / powershell / python / javascript / ruby / php / c / csharp / lua / perl:对应语言的脚本文件
- custom:自定义解释器,额外需要 customOptions: { bin, argv, ext }
除了 quickcommand 和 quickcomposer,其它 program 都要求 cmd 是脚本正文,额外可选:
- scptarg:命令行参数
- charset: { scriptCode: "gbk", outputCode: "gbk" }(Windows 中文环境常见)
## features
- code:命令的唯一标识,由插件生成。创建时不用填(填了也会重新生成),更新时不要改这个字段。
- explain:必填,同时会作为匹配规则的 label
- cmds:必填数组,每个元素是「触发方式」:
- 字符串:关键词,如 "翻译"
- { type: "regex", match: "/^\\\\d+$/i" }:正则匹配主输入框或选中的文本
- { type: "over" }:匹配任意文本(未被其它插件占用时)
- { type: "files", fileType: "file"|"directory", extensions: ["png","jpg"], match: "/\\\\.png$/i" }
- { type: "img" }:匹配剪贴板图片
- { type: "window", match: { app: ["chrome.exe"], title: "/github/", class: ["..." ] } }:匹配当前活动窗口
- 多种类型混用时会自动变成 "professional"
- platform:命令生效的系统,默认三个平台都生效
- mainPush:为 true 时命令出现在 uTools 主输入框的推送列表里(输出类型会被限制)
- icon:data URL 或相对路径。**建议留空**,留空时会自动用该执行方式的默认图标(如 shell 命令用 logo/shell.png),用户可以在界面里改成自己喜欢的。自己传的话超过 32KB 会报错
## output(输出方式)
- text(默认):在 uTools 窗口显示
- html:按 HTML 渲染
- nothing:忽略输出且不关闭窗口
- ignore:忽略输出并隐藏窗口
- terminal:在终端显示
- clip:复制到剪贴板
- send:发送到当前活动窗口
- notice:发送系统通知
## tags
字符串数组,如 ["日常", "图片"],留空则归入「未分类」。
## flows(quickcomposer 专用)
可视化编排的内容,结构复杂且随版本变化,**不要凭空生成**。要创建编排命令,请让用户在插件界面里拖拽生成,再用 qc_get_command 读取、用 qc_update_command 做小范围调整。
## 示例一:JavaScript 脚本命令
{
"program": "quickcommand",
"cmd": "quickcommand.showInputBox(['要加密的内容']).then(([text]) => {\\n console.log(quickcommand.md5(text))\\n})",
"features": {
"explain": "计算 MD5",
"cmds": ["计算md5"]
},
"output": "text",
"tags": ["日常"]
}
## 示例二:Shell 命令
{
"program": "shell",
"cmd": "ls -la ~/Downloads | head -20",
"features": {
"explain": "列出下载目录",
"cmds": [{ "type": "over", "label": "列出下载目录" }]
},
"output": "text",
"tags": []
}
## 返回值约定
所有工具都返回对象:成功是 { ok: true, ... },失败是 { ok: false, error: "原因" },写操作的参数不合法时额外返回 errors: [{ path: "features.cmds[0].type", got: "regexp", allow: ["key","regex","over","img","files","window","professional"] }],照着 errors 改参数重试即可。
## 内置命令
插件自带一批 code 以 default_ 开头的默认命令,它们不落库、不参与 Qc 的读写,qc_list_commands 不会列出它们,也不能修改或删除。用户想调整内置命令时,用 qc_create_command 新建一条自己的即可。
`;
+360
View File
@@ -0,0 +1,360 @@
/**
* 快捷命令的读写与校验,供 MCP 工具使用(preload 环境,CommonJS)
*
* 逻辑对应 src/js/commandManager.js,那边是 webpack 模块,preload require 不到,
* 只能把必需片段复制过来。改动其中一边时记得同步另一边。
*/
const utoolsFull = window.utools; // App.vue 初始化后 window.utools 会被替换成阉割版,必须在 preload 加载时捕获
const PROGRAMS = [
"quickcommand",
"quickcomposer",
"html",
"shell",
"applescript",
"cmd",
"powershell",
"python",
"javascript",
"ruby",
"php",
"c",
"csharp",
"lua",
"perl",
"custom",
];
const OUTPUTS = [
"ignore",
"nothing",
"text",
"html",
"terminal",
"clip",
"send",
"notice",
];
const PLATFORMS = ["win32", "darwin", "linux"];
const CMD_TYPES = [
"key",
"regex",
"over",
"img",
"files",
"window",
"professional",
];
const ICON_MAX_LENGTH = 32 * 1024;
// 各执行方式的默认图标,与 src/js/options/programs.js 保持一致(那边是 webpack 模块,preload 引不到)
const DEFAULT_ICONS = {
quickcommand: "logo/quickcommand.png",
quickcomposer: "logo/quickcommand.png",
html: "logo/html.png",
shell: "logo/shell.png",
applescript: "logo/applescript.png",
cmd: "logo/cmd.png",
powershell: "logo/powershell.png",
python: "logo/python.png",
javascript: "logo/javascript.png",
ruby: "logo/ruby.png",
php: "logo/php.png",
c: "logo/c.png",
csharp: "logo/csharp.png",
lua: "logo/lua.png",
perl: "logo/perl.png",
custom: "logo/custom.png",
};
const getUniqueId = (options = {}) => {
const { short = false } = options;
const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
return short ? uuid.substring(0, 8) : uuid;
};
const isDefaultCommand = (code) => String(code).slice(0, 8) === "default_";
const getCmdType = (cmds) => {
const firstCmdType = cmds[0].type || "key";
if (!cmds.find((x) => typeof x !== "string")) return "key";
if (!cmds.find((x) => x.type !== firstCmdType)) return firstCmdType;
return "professional";
};
const getFeatureCode = (cmds) => {
return `${getCmdType(cmds)}_${getUniqueId({ short: true })}`;
};
const dbGet = (id) => {
const db = utoolsFull.db.get(id);
return db ? db.data : null;
};
const dbPut = (value, id) => {
const db = utoolsFull.db.get(id);
return db
? utoolsFull.db.put({ _id: id, data: value, _rev: db._rev })
: utoolsFull.db.put({ _id: id, data: value });
};
const dbRemove = (id) => utoolsFull.db.remove(id);
const getActivatedCodes = () => utoolsFull.getFeatures().map((x) => x.code);
// 是否为不需要显示输入框的输出类型
const outPluginOutputs = ["ignore", "terminal", "clip", "send", "notice"];
const validateCmdRule = (rule, index, errors) => {
const path = `features.cmds[${index}]`;
if (typeof rule === "string") {
if (!rule.trim())
errors.push({ path, error: "关键词不能为空字符串" });
return;
}
if (!rule || typeof rule !== "object") {
errors.push({ path, got: rule, error: "只能是字符串(关键词)或匹配规则对象" });
return;
}
if (!CMD_TYPES.includes(rule.type)) {
errors.push({ path: `${path}.type`, got: rule.type, allow: CMD_TYPES });
return;
}
if (rule.type === "regex" && typeof rule.match !== "string") {
errors.push({ path: `${path}.match`, error: "正则匹配规则需要 match 字段,形如 \"/^https?:\\\\/\\\\//i\"" });
}
if (rule.type === "files" && rule.fileType && !["file", "directory"].includes(rule.fileType)) {
errors.push({ path: `${path}.fileType`, got: rule.fileType, allow: ["file", "directory"] });
}
if (rule.type === "window" && (!rule.match || !Array.isArray(rule.match.app))) {
errors.push({ path: `${path}.match.app`, error: "窗口匹配规则需要 match.app 数组,形如 [\"chrome.exe\"]" });
}
};
const validateCommand = (command) => {
const errors = [];
if (!command || typeof command !== "object" || Array.isArray(command)) {
return [{ path: "", error: "命令必须是一个对象" }];
}
if (!PROGRAMS.includes(command.program)) {
errors.push({ path: "program", got: command.program, allow: PROGRAMS });
}
const features = command.features;
if (!features || typeof features !== "object") {
errors.push({ path: "features", error: "缺少 features 对象" });
return errors;
}
if (typeof features.explain !== "string" || !features.explain.trim()) {
errors.push({ path: "features.explain", error: "名称不能为空,它是命令在 uTools 搜索框里显示的名字" });
}
if (!Array.isArray(features.cmds) || !features.cmds.length) {
errors.push({ path: "features.cmds", error: "至少需要一个触发方式(关键词字符串或匹配规则对象)" });
} else {
features.cmds.forEach((rule, i) => validateCmdRule(rule, i, errors));
}
if (features.platform !== undefined) {
if (
!Array.isArray(features.platform) ||
!features.platform.length ||
features.platform.some((p) => !PLATFORMS.includes(p))
) {
errors.push({ path: "features.platform", got: features.platform, allow: PLATFORMS });
}
}
if (features.mainPush !== undefined && typeof features.mainPush !== "boolean") {
errors.push({ path: "features.mainPush", got: features.mainPush, error: "需要布尔值" });
}
if (typeof features.icon === "string" && features.icon.length > ICON_MAX_LENGTH) {
errors.push({
path: "features.icon",
error: `图标过大(${features.icon.length} 字符,上限 ${ICON_MAX_LENGTH}),请留空让用户自己在界面里设置`,
});
}
if (command.output !== undefined && !OUTPUTS.includes(command.output)) {
errors.push({ path: "output", got: command.output, allow: OUTPUTS });
}
if (command.tags !== undefined && !Array.isArray(command.tags)) {
errors.push({ path: "tags", got: command.tags, error: "需要字符串数组,如 [\"日常\"]" });
}
if (command.program === "quickcomposer") {
if (command.flows !== undefined && !Array.isArray(command.flows)) {
errors.push({ path: "flows", got: command.flows, error: "需要数组" });
}
} else if (typeof command.cmd !== "string") {
errors.push({ path: "cmd", got: command.cmd, error: "脚本内容必须是字符串" });
}
return errors;
};
// 补齐派生字段,等价于 commandManager 里的 getValidCommand
const normalizeCommand = (command) => {
const normalized = JSON.parse(JSON.stringify(command));
const { cmds, explain } = normalized.features;
normalized.features.cmds = cmds.map((cmd) =>
typeof cmd === "string" ? cmd : { ...cmd, label: explain }
);
normalized.features.mainHide = outPluginOutputs.includes(normalized.output);
normalized.features.code =
normalized.features.code || getFeatureCode(normalized.features.cmds);
// 图标留空时用该类型的默认图标,否则命令卡片上是个空位
if (!normalized.features.icon) {
normalized.features.icon =
DEFAULT_ICONS[normalized.program] || DEFAULT_ICONS.quickcommand;
}
if (!Array.isArray(normalized.tags)) normalized.tags = [];
if (normalized.output === undefined) normalized.output = "text";
return normalized;
};
const getDbCommands = () => {
return utoolsFull.db
.allDocs("qc_")
.map((x) => x.data)
// 打包进插件里的默认命令不落库,这条只是兜底
.filter((x) => x && x.features && !String(x.features.code).includes("default_"));
};
const summarize = (command, activatedCodes) => ({
code: command.features.code,
explain: command.features.explain,
cmds: command.features.cmds,
program: command.program,
output: command.output,
tags: command.tags || [],
enabled: activatedCodes.includes(command.features.code),
});
const list = (params = {}) => {
const activatedCodes = getActivatedCodes();
let commands = getDbCommands();
if (params.tag) commands = commands.filter((x) => (x.tags || []).includes(params.tag));
if (params.program) commands = commands.filter((x) => x.program === params.program);
if (typeof params.enabled === "boolean") {
commands = commands.filter((x) => activatedCodes.includes(x.features.code) === params.enabled);
}
if (params.keyword) {
const kw = String(params.keyword).toLowerCase();
commands = commands.filter((x) => {
const haystack = [
x.features.code,
x.features.explain,
JSON.stringify(x.features.cmds),
(x.tags || []).join(" "),
]
.join(" ")
.toLowerCase();
return haystack.includes(kw);
});
}
return {
total: commands.length,
commands: commands.map((x) => summarize(x, activatedCodes)),
};
};
const get = (code) => {
if (!code) throw new Error("缺少 code 参数");
if (isDefaultCommand(code)) {
throw new Error(
`${code} 是插件内置的默认命令,不落库也没有对应的数据,MCP 无法读取。内置命令一律只读,需要类似功能请用 qc_create_command 新建一条。`
);
}
const command = dbGet("qc_" + code);
if (!command) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`);
return command;
};
const create = (command) => {
const errors = validateCommand(command);
if (errors.length) return { errors };
const normalized = normalizeCommand(command);
if (isDefaultCommand(normalized.features.code)) {
normalized.features.code = getFeatureCode(normalized.features.cmds);
}
return { command: persist(normalized) };
};
const update = (code, command) => {
if (!code) throw new Error("缺少 code 参数");
if (isDefaultCommand(code)) {
throw new Error(`${code} 是插件内置的默认命令,不能修改。要改请用 qc_create_command 新建一条自己的命令。`);
}
const current = dbGet("qc_" + code);
if (!current) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`);
const errors = validateCommand(command);
if (errors.length) return { errors };
const normalized = normalizeCommand({ ...command, features: { ...command.features, code } });
return { command: persist(normalized) };
};
const remove = (code) => {
if (!code) throw new Error("缺少 code 参数");
if (isDefaultCommand(code)) {
throw new Error(`${code} 是插件内置的默认命令,不能删除。`);
}
const command = dbGet("qc_" + code);
if (!command) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`);
dbRemove("qc_" + code);
utoolsFull.removeFeature(code);
// 历史记录一并清掉,等价于界面上的删除
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("editor_history_" + code)) localStorage.removeItem(key);
}
return command;
};
const setEnabled = (codes, enabled) => {
const changed = [];
codes.forEach((code) => {
if (isDefaultCommand(code)) return;
const command = dbGet("qc_" + code);
if (!command) return;
utoolsFull.removeFeature(code);
if (enabled) utoolsFull.setFeature(window.lodashM.cloneDeep(command.features));
changed.push({ code, enabled });
});
return changed;
};
const listTags = () => {
const counts = {};
getDbCommands().forEach((x) => {
const tags = x.tags && x.tags.length ? x.tags : ["未分类"];
tags.forEach((t) => (counts[t] = (counts[t] || 0) + 1));
});
return Object.keys(counts).map((tag) => ({ tag, count: counts[tag] }));
};
// 落库 + 注册 feature,等价于界面保存
const persist = (command) => {
const code = command.features.code;
dbPut(command, "qc_" + code);
utoolsFull.removeFeature(code);
utoolsFull.setFeature(window.lodashM.cloneDeep(command.features));
notifyUI();
return command;
};
// 通知页面刷新列表,页面没打开时没人监听,忽略即可
const notifyUI = () => {
try {
window.dispatchEvent(new CustomEvent("qc:mcp-changed"));
} catch (e) {
console.log("notifyUI failed", e);
}
};
module.exports = {
list,
get,
create,
update,
remove,
setEnabled,
listTags,
notifyUI,
};
+147
View File
@@ -0,0 +1,147 @@
/**
* 把快捷命令的增删改查注册成 uTools 的 MCP 工具,供外部 AI Agent 调用
*
* 工具声明在 plugin/plugin.json 的 tools 字段里,这里只负责运行时注册 handler。
* 三档权限(读取/写入/运行)在 AI 配置界面里开关,存在本机 profile 的 mcpPermissions 字段。
*/
const store = require("./commandStore.js");
const schema = require("./commandSchema.js");
const utoolsFull = window.utools; // App.vue 初始化后 window.utools 会被替换成阉割版,必须在 preload 加载时捕获
const PROFILE_KEY = () => `cfg_${utoolsFull.getNativeId()}_profile`;
const DEFAULT_PERMISSIONS = { read: true, write: true, run: false };
const GROUP_LABELS = { read: "读取", write: "写入", run: "运行" };
const TOOL_GROUPS = {
read: ["qc_list_commands", "qc_get_command", "qc_list_tags", "qc_get_schema"],
write: [
"qc_create_command",
"qc_update_command",
"qc_delete_command",
"qc_set_enabled",
],
run: ["qc_run_command"],
};
const getPermissions = () => {
let saved = {};
try {
const profile = utoolsFull.db.get(PROFILE_KEY());
saved = (profile && profile.data && profile.data.mcpPermissions) || {};
} catch (e) {
console.log("读取 MCP 权限配置失败", e);
}
return { ...DEFAULT_PERMISSIONS, ...saved };
};
const validationFailed = (errors) => ({
ok: false,
error: "参数校验没通过,按 errors 里的提示修正后重试",
errors,
});
const HANDLERS = {
qc_list_commands: (params) => ({ ok: true, ...store.list(params) }),
qc_get_command: (params) => ({
ok: true,
command: store.get(params.code),
}),
qc_list_tags: () => ({ ok: true, tags: store.listTags() }),
qc_get_schema: () => ({ ok: true, schema }),
qc_create_command: (params) => {
const result = store.create(params.command);
if (result.errors) return validationFailed(result.errors);
return {
ok: true,
code: result.command.features.code,
command: result.command,
};
},
qc_update_command: (params) => {
const result = store.update(params.code, params.command);
if (result.errors) return validationFailed(result.errors);
return { ok: true, command: result.command };
},
qc_delete_command: (params) => ({
ok: true,
command: store.remove(params.code),
}),
qc_set_enabled: (params) => {
if (!Array.isArray(params.codes) || !params.codes.length) {
return validationFailed([
{ path: "codes", got: params.codes, error: "需要非空的 code 数组" },
]);
}
const changed = store.setEnabled(params.codes, params.enabled === true);
if (!changed.length) {
return {
ok: false,
error: "没有匹配到可操作的命令(内置命令不能启用/禁用,也可以先用 qc_list_commands 确认 code)",
};
}
store.notifyUI();
return { ok: true, changed };
},
qc_run_command: async (params) => {
// 先确认命令真的存在,避免把空对象丢给界面侧的运行器
store.get(params.code);
// 运行要靠界面侧的命令运行器(需要窗口、输出面板配合),插件页面没加载时用不了
if (!window.qcBridge || typeof window.qcBridge.runCommand !== "function") {
return {
ok: false,
error:
"运行命令需要 quickcommand 插件界面处于加载状态。请在 uTools 里打开一次「快捷命令」后重试,或改用界面上的运行按钮。",
};
}
const output = await window.qcBridge.runCommand(
params.code,
params.input,
params.timeout || 10000
);
return { ok: true, code: params.code, output };
},
};
const groupOf = (name) =>
Object.keys(TOOL_GROUPS).find((group) => TOOL_GROUPS[group].includes(name));
// handler 里再查一次权限:uTools 的 tools/list 只认 plugin.json 里的声明,
// 反注册拦不住调用,只会让 Agent 收到「未注册」这种没法行动的错,所以这里给出明确原因
const withPermissionCheck = (name) => {
const group = groupOf(name);
return async (params, ctx) => {
if (!getPermissions()[group]) {
return {
ok: false,
error: `用户在 quickcommand 的 AI 配置里关闭了 AI Agent 的「${GROUP_LABELS[group]}」权限,打开后重试`,
};
}
try {
return await HANDLERS[name](params || {}, ctx);
} catch (e) {
console.log("MCP 工具执行失败:" + name, e);
return { ok: false, error: String((e && e.message) || e) };
}
};
};
const supported = () => typeof utoolsFull.registerTool === "function";
const applyPermissions = () => {
if (!supported()) {
console.log("当前 uTools 版本不支持 registerTool,跳过 MCP 工具注册");
return getPermissions();
}
Object.keys(TOOL_GROUPS).forEach((group) => {
TOOL_GROUPS[group].forEach((name) => {
utoolsFull.registerTool(name, withPermissionCheck(name));
});
});
return getPermissions();
};
// 权限在每次调用时从 profile 里读,所以不需要再暴露重注册的接口
applyPermissions();
module.exports = { applyPermissions, getPermissions, TOOL_GROUPS };
+118 -1
View File
@@ -70,5 +70,122 @@
],
"icon": "features/server.png"
}
]
],
"tools": {
"qc_list_commands": {
"description": "列出用户自己创建的快捷命令(只返回摘要,不含脚本正文)。可用 tag、keyword、program、enabled 过滤。插件内置的 default_ 命令不会列出,也不能通过 MCP 读写。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"tag": { "type": "string", "description": "只看某个标签下的命令" },
"keyword": { "type": "string", "description": "按名称、关键词、匹配规则、标签模糊搜索" },
"program": {
"type": "string",
"enum": ["quickcommand", "quickcomposer", "html", "shell", "applescript", "cmd", "powershell", "python", "javascript", "ruby", "php", "c", "csharp", "lua", "perl", "custom"],
"description": "只看某种执行方式的命令"
},
"enabled": { "type": "boolean", "description": "只看已启用(true)或已禁用(false)的命令" }
}
}
},
"qc_get_command": {
"description": "按 code 取一条快捷命令的完整 JSON(含脚本正文、匹配规则、输出方式)。修改前先读一次,改完用 qc_update_command 整体写回。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": { "type": "string", "description": "命令的唯一标识,由 qc_list_commands 返回" }
},
"required": ["code"]
}
},
"qc_list_tags": {
"description": "列出所有标签以及每个标签下的命令数量,用于把新命令归类和按标签筛选。",
"inputSchema": { "type": "object", "additionalProperties": false }
},
"qc_get_schema": {
"description": "返回快捷命令的数据结构说明、各种 program 和匹配类型的含义、输出方式枚举,以及可直接照抄的完整示例。第一次创建或修改命令之前先调用它。",
"inputSchema": { "type": "object", "additionalProperties": false }
},
"qc_create_command": {
"description": "新建一条快捷命令。传入命令对象(字段说明见 qc_get_schema),服务端会补齐 code、匹配规则的 label 等派生字段,并注册到 uTools 让它可以被关键词唤起。返回新建命令的完整内容和 code。参数不合法时返回 errors 数组,按提示改完重试。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"command": {
"type": "object",
"description": "完整命令对象,至少包含 program、features.explain、features.cmds,其余字段参考 qc_get_schema",
"properties": {
"program": { "type": "string", "description": "执行方式,如 quickcommand、shell、python" },
"features": {
"type": "object",
"properties": {
"explain": { "type": "string", "description": "命令名称,不能为空" },
"cmds": { "type": "array", "description": "触发方式数组:关键词字符串,或 {type, match} 形式的匹配规则" },
"icon": { "type": "string", "description": "建议留空,留空时自动用该执行方式的默认图标" },
"platform": { "type": "array", "items": { "type": "string" }, "description": "生效平台,默认三个平台都生效" }
},
"required": ["explain", "cmds"]
},
"cmd": { "type": "string", "description": "脚本正文,program 为 quickcommand 等脚本类型时必填" },
"output": { "type": "string", "enum": ["ignore", "nothing", "text", "html", "terminal", "clip", "send", "notice"], "description": "输出方式,默认 text" },
"tags": { "type": "array", "items": { "type": "string" }, "description": "标签,留空归入未分类" }
},
"required": ["program", "features"]
}
},
"required": ["command"]
}
},
"qc_update_command": {
"description": "整体替换一条命令的内容,按 code 定位,code 本身不能改。参数必须是一条完整命令:先用 qc_get_command 读出来,改完再整条写回,只传要改的字段会被判为参数不合法。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": { "type": "string", "description": "要修改的命令的 code" },
"command": { "type": "object", "description": "修改后的完整命令对象,字段说明见 qc_get_schema" }
},
"required": ["code", "command"]
}
},
"qc_delete_command": {
"description": "删除一条命令,返回被删除的命令内容以便确认删对了。插件内置的 default_ 命令不能删除。删除不可撤销,删之前先 qc_get_command 确认一下。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": { "type": "string", "description": "要删除的命令的 code" }
},
"required": ["code"]
}
},
"qc_set_enabled": {
"description": "启用或禁用一条或多条命令,等价于插件界面命令卡片上的开关。禁用的命令不会出现在 uTools 的搜索结果里,但数据仍然保留。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"codes": { "type": "array", "items": { "type": "string" }, "description": "要操作的命令 code 列表" },
"enabled": { "type": "boolean", "description": "true 启用,false 禁用" }
},
"required": ["codes", "enabled"]
}
},
"qc_run_command": {
"description": "运行一条命令并返回它的输出,等价于点界面上命令卡片的运行按钮。需要 uTools 保持运行,插件进程被回收时会返回提示。只返回第一次输出的内容,输出方式为剪贴板/通知/终端的命令不会回传结果。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": { "type": "string", "description": "要运行的命令的 code" },
"input": { "type": "string", "description": "替换脚本里 {{input}} 占位符的输入内容,可选" },
"timeout": { "type": "number", "description": "等待输出的毫秒数,默认 10000" }
},
"required": ["code"]
}
}
}
}
+2
View File
@@ -45,6 +45,8 @@ window.convertFilePathToUtoolsPayload = convertFilePathToUtoolsPayload;
window.getuToolsLite = require("./lib/utoolsLite");
window.quickcommand = require("./lib/quickcommand");
window.quickcomposer = require("./lib/quickcomposer");
// 注册 AI Agent 可调用的 MCP 工具(声明在 plugin.json 的 tools 字段里)
require("./lib/quickcommandMcp");
window.showUb = require("./lib/showDocs");
window.getQuickcommandTempFile =
require("./lib/getQuickcommandFile").getQuickcommandTempFile;
+9
View File
@@ -38,6 +38,15 @@ export default defineComponent({
};
},
created: function () {
// 给 preload 里的 MCP 工具用的桥接(运行命令需要界面侧的命令运行器)
window.qcBridge = {
runCommand: (code, input, timeout) =>
this.runCommand(code, input, timeout),
};
// Agent 改了命令后刷新列表
window.addEventListener("qc:mcp-changed", () => {
this.commandManager.getAllQuickCommands();
});
this.init();
},
methods: {
+28 -1
View File
@@ -161,7 +161,26 @@
</draggable>
</q-scroll-area>
</div>
<div class="flex justify-end q-gutter-sm q-px-sm">
<q-separator class="q-mt-md" />
<div class="q-px-sm q-pt-md">
<div class="row items-center q-gutter-sm">
<q-icon name="smart_toy" size="20px" />
<span class="text-subtitle1 text-weight-bold">AI Agent(MCP)</span>
<q-toggle dense v-model="mcpPermissions.read" label="读取">
<q-tooltip>允许 Agent 查询命令列表和内容</q-tooltip>
</q-toggle>
<q-toggle dense v-model="mcpPermissions.write" label="写入">
<q-tooltip>允许 Agent 新建、修改、删除、启用/禁用命令</q-tooltip>
</q-toggle>
<q-toggle dense v-model="mcpPermissions.run" label="运行">
<q-tooltip>允许 Agent 直接运行命令,建议只在需要时开</q-tooltip>
</q-toggle>
</div>
<div class="text-grey-7 q-mt-xs" style="font-size: 12px">
开启后,外部 AI Agent 可以通过 uTools 的本地 MCP 服务增删改查快捷命令(需先在 uTools 设置 → MCP 服务里开启本地服务,并把地址写进 Agent 的配置)
</div>
</div>
<div class="flex justify-end q-gutter-sm q-px-sm q-mt-md">
<q-btn flat color="grey" label="取消" v-close-popup />
<q-btn
flat
@@ -205,6 +224,7 @@ export default defineComponent({
aiConfigs: [],
models: [],
tokenInputTypes: [],
mcpPermissions: { read: true, write: true, run: false },
};
},
emits: ["save"],
@@ -238,6 +258,9 @@ export default defineComponent({
"cfg_aiConfigs",
window.lodashM.cloneDeep(this.aiConfigs)
);
// MCP 权限存在本机 profile 里
this.$root.nativeProfile.mcpPermissions = { ...this.mcpPermissions };
this.$root.saveProfile();
this.$emit("save");
},
deleteModel(index) {
@@ -272,6 +295,10 @@ export default defineComponent({
id: config.id || getUniqueId(),
})
);
this.mcpPermissions = {
...this.mcpPermissions,
...(this.$root.nativeProfile.mcpPermissions || {}),
};
},
});
</script>
+122
View File
@@ -0,0 +1,122 @@
/**
* commandStore 的自检脚本(不依赖 uTools,用假 db 跑一遍增删改查)
* 运行:node test/commandStore.test.js
*/
const assert = require("assert");
const db = new Map();
const features = new Map();
global.window = {
lodashM: { cloneDeep: (x) => JSON.parse(JSON.stringify(x)) },
dispatchEvent: () => true,
utools: {
db: {
get: (id) => (db.has(id) ? { _id: id, _rev: "1-x", data: db.get(id) } : undefined),
put: (doc) => (db.set(doc._id, doc.data), { ok: true }),
remove: (id) => (db.delete(id), { ok: true }),
allDocs: (prefix) =>
[...db.entries()]
.filter(([id]) => id.startsWith(prefix))
.map(([id, data]) => ({ _id: id, data })),
},
getFeatures: () => [...features.values()],
setFeature: (feature) => (features.set(feature.code, feature), true),
removeFeature: (code) => features.delete(code),
},
};
global.localStorage = { length: 0, key: () => null, removeItem: () => {} };
const store = require("../plugin/lib/commandStore.js");
const draft = {
program: "quickcommand",
cmd: "console.log(1)",
features: { explain: "测试命令", cmds: ["测试命令"] },
output: "text",
tags: ["测试"],
};
// 建
const created = store.create(draft);
assert.deepStrictEqual(created.errors, undefined);
const command = created.command;
const code = command.features.code;
assert.match(code, /^key_[0-9a-f]{8}$/);
assert.strictEqual(command.features.cmds[0], "测试命令");
assert.strictEqual(command.features.mainHide, false);
// 不带图标时用该执行方式的默认图标
assert.strictEqual(command.features.icon, "logo/quickcommand.png");
const pyCommand = store.create({
program: "python",
cmd: "print(1)",
features: { explain: "py", cmds: ["py"] },
}).command;
assert.strictEqual(pyCommand.features.icon, "logo/python.png");
const customCommand = store.create({
program: "custom",
cmd: "x",
features: { explain: "自定义", cmds: ["自定义"], icon: "data:image/png;base64,xxx" },
}).command;
assert.strictEqual(customCommand.features.icon, "data:image/png;base64,xxx");
store.remove(pyCommand.features.code);
store.remove(customCommand.features.code);
assert.ok(db.has("qc_" + code));
assert.ok(features.has(code));
// 参数不合法时不落库
const before = db.size;
assert.ok(store.create({ ...draft, program: "python3" }).errors.length);
assert.ok(store.create({ ...draft, features: { explain: "", cmds: [] } }).errors.length);
assert.strictEqual(db.size, before);
// 查
assert.strictEqual(store.list({}).total, 1);
assert.strictEqual(store.list({ tag: "测试" }).total, 1);
assert.strictEqual(store.list({ tag: "不存在" }).total, 0);
assert.strictEqual(store.list({ keyword: "测试" }).total, 1);
assert.strictEqual(store.list({ enabled: true }).total, 1);
assert.strictEqual(store.list({ enabled: false }).total, 0);
assert.deepStrictEqual(store.listTags(), [{ tag: "测试", count: 1 }]);
assert.strictEqual(store.get(code).cmd, "console.log(1)");
// 改
const updated = store.update(code, {
...command,
cmd: "console.log(2)",
features: { ...command.features, explain: "改名了" },
});
assert.strictEqual(updated.command.features.code, code);
assert.strictEqual(updated.command.features.explain, "改名了");
assert.strictEqual(db.get("qc_" + code).cmd, "console.log(2)");
// 字符串形式的触发方式是关键词,不派生 label;对象形式的匹配规则 label 跟随 explain
assert.strictEqual(db.get("qc_" + code).features.cmds[0], "测试命令");
const withRule = store.create({
...draft,
features: { explain: "正则命令", cmds: [{ type: "regex", match: "/^\\d+$/i" }] },
}).command;
assert.strictEqual(withRule.features.cmds[0].label, "正则命令");
assert.ok(store.update(code, { ...command, tags: "不是数组" }).errors.length);
assert.ok(store.update(code, { ...command, features: { ...command.features, icon: "x".repeat(40000) } }).errors.length);
// 启用/禁用
assert.deepStrictEqual(store.setEnabled([code], false), [{ code, enabled: false }]);
assert.ok(!features.has(code));
assert.strictEqual(store.list({ enabled: false }).total, 1);
store.setEnabled([code], true);
assert.ok(features.has(code));
// 内置命令一律只读
assert.throws(() => store.get("default_textReplacer"), /内置/);
assert.throws(() => store.remove("default_textReplacer"), /内置/);
assert.throws(() => store.update("default_textReplacer", draft), /内置/);
assert.notDeepStrictEqual(store.create({ ...draft, features: { ...draft.features, code: "default_x" } }).command.features.code, "default_x");
// 删
const removed = store.remove(code);
assert.strictEqual(removed.features.code, code);
assert.ok(!db.has("qc_" + code));
assert.ok(!features.has(code));
assert.throws(() => store.get(code), /没有找到/);
console.log("commandStore 自检通过");
+153
View File
@@ -0,0 +1,153 @@
/**
* uTools MCP 服务的端到端自检:打一遍 initialize / tools/list / 增删改查
*
* 用法:node test/mcp-smoke.mjs http://127.0.0.1:3501/mcp <x-mcp-key>
* 端口和 key 在 uTools 设置 → MCP 服务 里。
*/
const [url, key] = process.argv.slice(2);
if (!url || !key) {
console.error("用法:node test/mcp-smoke.mjs <mcp-url> <x-mcp-key>");
process.exit(1);
}
let sessionId = null;
let nextId = 1;
let allTools = [];
const call = async (method, params) => {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"x-mcp-key": key,
...(sessionId ? { "mcp-session-id": sessionId } : {}),
},
body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),
});
const newSession = res.headers.get("mcp-session-id");
if (newSession) sessionId = newSession;
const text = await res.text();
if (!res.ok) throw new Error(`${method} HTTP ${res.status}: ${text.slice(0, 200)}`);
const payload = text
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("");
const parsed = JSON.parse(payload || text);
if (parsed.error) throw new Error(`${method} 返回错误:${JSON.stringify(parsed.error)}`);
return parsed.result;
};
const callTool = async (bareName, args = {}) => {
// uTools 会给工具名加上插件前缀,tools/call 必须用完整名字
const tool = allTools.find((t) => t.name === bareName || t.name.endsWith("_" + bareName));
const result = await call("tools/call", { name: tool ? tool.name : bareName, arguments: args });
const text = (result.content || []).map((c) => c.text || "").join("");
try {
return JSON.parse(text);
} catch (e) {
return { raw: text, isError: result.isError };
}
};
const check = (label, condition, detail) => {
console.log(`${condition ? "✅" : "❌"} ${label}${detail ? " — " + detail : ""}`);
if (!condition) process.exitCode = 1;
};
await call("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "quickcommand-smoke", version: "1" },
});
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"x-mcp-key": key,
"mcp-session-id": sessionId,
},
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
});
console.log("✅ initialize");
const { tools } = await call("tools/list");
allTools = tools;
const ours = tools.map((t) => t.name).filter((n) => n.includes("qc_"));
check("tools/list 里有 quickcommand 的工具", ours.length > 0, ours.join(", ") || "一个都没有");
if (!ours.length) {
console.log(
"提示:确认 uTools 的 MCP 设置里没有禁用 quickcommand,且插件已重新加载过 plugin.json"
);
process.exit(1);
}
const schema = await callTool("qc_get_schema");
check("qc_get_schema", schema.ok === true && schema.schema.length > 500);
const before = await callTool("qc_list_commands");
check("qc_list_commands", before.ok === true, `当前 ${before.total} 条用户命令`);
const created = await callTool("qc_create_command", {
command: {
program: "quickcommand",
cmd: "console.log('hello from mcp')",
features: { explain: "MCP 自检命令", cmds: ["mcp自检"] },
output: "text",
tags: ["自检"],
},
});
check("qc_create_command", created.ok === true && /^key_/.test(created.code || ""), created.code);
if (!created.ok) {
console.log(JSON.stringify(created, null, 2));
process.exit(1);
}
const code = created.code;
// 故意写错,确认返回可读的校验错误
const bad = await callTool("qc_create_command", {
command: { program: "python3", features: { explain: "", cmds: [{ type: "regexp" }] } },
});
check("参数不合法时返回 errors", bad.ok === false && Array.isArray(bad.errors), `${(bad.errors || []).length} 条`);
const one = await callTool("qc_get_command", { code });
check("qc_get_command", one.ok === true && one.command.cmd.includes("hello from mcp"));
const listed = await callTool("qc_list_commands", { keyword: "自检" });
check("qc_list_commands 能按关键词搜到", (listed.commands || []).some((c) => c.code === code));
const updated = await callTool("qc_update_command", {
code,
command: { ...one.command, cmd: "console.log('updated')" },
});
check("qc_update_command", updated.ok === true && updated.command.cmd.includes("updated"));
const disabled = await callTool("qc_set_enabled", { codes: [code], enabled: false });
const offList = await callTool("qc_list_commands", { enabled: false });
check(
"qc_set_enabled 禁用后列表可见",
disabled.ok === true && offList.commands.some((c) => c.code === code)
);
await callTool("qc_set_enabled", { codes: [code], enabled: true });
const tags = await callTool("qc_list_tags");
check("qc_list_tags", tags.ok === true && tags.tags.some((t) => t.tag === "自检"));
const run = await callTool("qc_run_command", { code });
// 运行档默认关:工具会被反注册,uTools 仍会列出它但调用报「未注册」
const runBlocked = run.ok !== true;
check(
"qc_run_command 的运行权限按开关生效",
runBlocked ? /未注册|权限/.test(run.raw || run.error || "") : run.ok === true,
runBlocked ? "运行档关闭(默认),工具未注册" : JSON.stringify(run.output)
);
const removed = await callTool("qc_delete_command", { code });
check("qc_delete_command", removed.ok === true && removed.command.features.code === code);
const builtin = await callTool("qc_delete_command", { code: "default_textReplacer" });
check("内置命令拒写", builtin.ok === false, builtin.error);
console.log(process.exitCode ? "有失败项" : "全部通过");