mirror of
https://github.com/fofolee/uTools-quickcommand.git
synced 2026-10-25 17:36:37 +08:00
Merge pull request #120 from Mangome/feat/mcp-agent
新增 AI Agent 的 MCP 工具,支持增删改查快捷命令
This commit is contained in:
11 files changed
+1058
-3
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 新建一条自己的即可。
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 自检通过");
|
||||
@@ -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 ? "有失败项" : "全部通过");
|
||||
Reference in new issue
Block a user