dsh-plugin-tool-management 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +90 -114
- package/README_EN.md +98 -119
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +63 -0
- package/lib/client.js +1086 -95
- package/lib/compat/preset-reach.js +6 -3
- package/lib/context-inject.js +183 -59
- package/lib/index.js +501 -49
- package/lib/mcp/manager.js +118 -71
- package/lib/mcp/secret-guard.js +21 -0
- package/lib/mcp/state-section.js +19 -14
- package/lib/memories/archive-engine.js +199 -0
- package/lib/memories/constants.js +72 -8
- package/lib/memories/index-io.js +1 -1
- package/lib/memories/projection.js +67 -31
- package/lib/memories/service.js +17 -2
- package/lib/memories/snapshot.js +123 -22
- package/lib/op-registry.js +279 -0
- package/lib/ops/candidates.js +11 -0
- package/lib/ops/compat.js +23 -2
- package/lib/ops/sessions.js +1 -1
- package/lib/ops/state-doctor.js +115 -0
- package/lib/prompts/service.js +25 -2
- package/lib/request-gate.js +21 -78
- package/lib/scene-settings.js +17 -0
- package/lib/scenes/candidates.js +56 -0
- package/lib/skills/catalog.js +10 -5
- package/lib/skills/core.js +102 -0
- package/lib/skills/service.js +34 -2
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +37 -4
- package/lib/subagents/tools.js +19 -5
- package/lib/tools/deps.js +15 -0
- package/lib/tools/mcp.js +291 -50
- package/lib/tools/memory.js +170 -19
- package/lib/tools/prompt.js +28 -14
- package/lib/tools/scene.js +351 -0
- package/lib/tools/skills.js +168 -14
- package/lib/tools/subagent.js +129 -3
- package/lib/tools/table.js +290 -0
- package/package.json +2 -2
package/lib/subagents/tools.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
|
22
22
|
export function defineSubagentManagerListTool(subagents) {
|
|
23
23
|
return {
|
|
24
24
|
name: 'subagent_manager_list',
|
|
25
|
-
description: 'List
|
|
25
|
+
description: 'List personas (pre-configured subagent profiles) with their descriptions. The「可委派的子智能体」reminder carries the same catalog; call this for the always-current full list.',
|
|
26
26
|
parameters: {},
|
|
27
27
|
output: {
|
|
28
28
|
schema: { type: 'string' },
|
|
@@ -59,11 +59,25 @@ export function defineSubagentManagerRunTool(subagents) {
|
|
|
59
59
|
// 「何时不用」那一段保留(2026-09-17,用户采纳的四条里的第 6 条):参照 Claude Code 的
|
|
60
60
|
// Agent 工具(`AgentTool/prompt.ts:232-240`),把"不该用"写成**带替代工具**的具体清单
|
|
61
61
|
// (具体路径→Read;找定义→Grep/Glob),比笼统说"这个很贵"有用得多。
|
|
62
|
-
|
|
62
|
+
//
|
|
63
|
+
// 2026-09-23(用户裁定):与官方两个委派工具的**分界规则**从描述里删掉 —— 注入通道的
|
|
64
|
+
// `how` 行(context-inject.ts 的 `DOMAIN_FRAME.subagents`)已经逐字说过一遍,而两份都在
|
|
65
|
+
// 每轮上下文里 = 同一件事付两次 token。留注入那份(它出现在"正在选工具"的那一刻)。
|
|
66
|
+
// 代价如实记下:子智能体域被关掉、或走压制型预设时上下文里没有那条 how 行,模型只剩本
|
|
67
|
+
// 描述与 `agent` 参数说明("Persona name from subagent_manager_list")—— 够它认出这条是
|
|
68
|
+
// 带人设的委派通道,但"没有人设贴合时才用官方那两个"这层分界就没人说了。
|
|
69
|
+
//
|
|
70
|
+
// 2026-09-23(用户裁定):本工具的 `task` / `inherit` 两个参数说明与上面的 Modes 段
|
|
71
|
+
// **逐字重复**,而工具表每轮都发 —— 同一件事付两次 token。实测 `subagent_manager_run`
|
|
72
|
+
// 整份 407 tok(参数段:`inherit` 86 / `task` 57 / `agent` 19),其中 `inherit` 是全表
|
|
73
|
+
// 最贵的单个参数。收完两处重复后目标 ≈325 tok。判据是"删掉的那句在描述里已经有了吗":
|
|
74
|
+
// Modes 段留着(它是模型的入口),参数说明只留"这一段独有的信息"。
|
|
75
|
+
// `inherit` 那句"轮中委派拿不到当前轮"必须留 —— 它是这条参数唯一会让人写错 task 的地方。
|
|
76
|
+
description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「可委派的子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
|
|
63
77
|
parameters: {
|
|
64
|
-
agent: { type: 'string', required: true, description: 'Persona name
|
|
65
|
-
task: { type: 'string', required: true, description: 'The task for the subagent
|
|
66
|
-
inherit: { type: 'boolean', description: 'Let the subagent
|
|
78
|
+
agent: { type: 'string', required: true, description: 'Persona name.' },
|
|
79
|
+
task: { type: 'string', required: true, description: 'The task for the subagent. Self-contained by default; with inherit: true only state what is new.' },
|
|
80
|
+
inherit: { type: 'boolean', description: 'Let the subagent see this conversation\'s finished turns (the current turn is never included). Default false = a fresh child.' },
|
|
67
81
|
},
|
|
68
82
|
output: {
|
|
69
83
|
schema: { type: 'string' },
|
package/lib/tools/deps.js
CHANGED
|
@@ -6,3 +6,18 @@
|
|
|
6
6
|
export function text(value) {
|
|
7
7
|
return [{ type: 'text', text: value }];
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* 名单渲染:超过 `TOOL_NAME_LIST_CAP` 项就截断,并把总数写在后面("等 N 项")。
|
|
11
|
+
*
|
|
12
|
+
* 为什么两个地方都要它:`scene_manager_list` 用它列档案,`scene_manager_save` 用它
|
|
13
|
+
* 在回执里回显"这一次替换挤掉了什么"。两处都必须**点名、不能只给数量** —— 只给数量
|
|
14
|
+
* 答不了"我这次给出去的段会不会盖掉原来勾着的那些",而那正是会静默出错的地方。
|
|
15
|
+
* 截断则是因为这些文字都要进上下文,不能无上限。
|
|
16
|
+
*/
|
|
17
|
+
export const TOOL_NAME_LIST_CAP = 6;
|
|
18
|
+
export function toolNameList(list) {
|
|
19
|
+
if (!list.length)
|
|
20
|
+
return '空';
|
|
21
|
+
const head = list.slice(0, TOOL_NAME_LIST_CAP).join('、');
|
|
22
|
+
return list.length > TOOL_NAME_LIST_CAP ? head + ' 等 ' + list.length + ' 项' : head;
|
|
23
|
+
}
|
package/lib/tools/mcp.js
CHANGED
|
@@ -1,18 +1,80 @@
|
|
|
1
|
-
// MCP 域的 model 工具(2026-09-19 从 index.ts
|
|
2
|
-
// mcp_manager_list /
|
|
1
|
+
// MCP 域的 model 工具(2026-09-19 从 index.ts 的注册区抽出;2026-09-23 合并):
|
|
2
|
+
// mcp_manager_list / mcp_manager_switch / mcp_manager_save。
|
|
3
|
+
//
|
|
4
|
+
// 两条合并理由(0.14.0 用户裁定):
|
|
5
|
+
// · set_enabled + restart → switch:两者都是"拨一下这台服务器",只是动词不同。合并后
|
|
6
|
+
// 由 `action` 自报动词,工具名不再需要用长度去区分"启停"与"重启"。
|
|
7
|
+
// · add + edit → save:改一台已配置的服务器(含 URL / 命令)与新增一台危险度同级
|
|
8
|
+
// (stdio 都是宿主按你给的 command 起进程),所以合并后**确认门必须留在 save 上**。
|
|
9
|
+
//
|
|
10
|
+
// 冻结口径跟着 `src/op-registry.ts` 走,不是"整条工具一刀切":`mcpm-set-enabled` 与
|
|
11
|
+
// `mcpm-tool-enabled` 在登记表里是 `frozen: true`,而 `mcpm-restart` 明确**不冻结**
|
|
12
|
+
// —— 锁定期间它是唯一还能落盘改补丁的入口,是"卡住了重连一下"这条恢复路径。
|
|
13
|
+
// 所以 switch 里只有 on|off 过 `lockedSceneGuard()`,restart 不过;与面板侧
|
|
14
|
+
// `guardLockedOps(frozenOps('all'))` 的口径逐字一致(两处口径不同才是真的"说假话")。
|
|
15
|
+
//
|
|
16
|
+
// **改一台已配置服务器时绝不读明文凭据**:`mcpm-edit` 的守卫(`resolveMaskedKv` /
|
|
17
|
+
// `resolveMaskedUrl`)就是为"接受打码值并还原"写的 —— 界面编辑框预填的本来就是打码值。
|
|
18
|
+
// 所以这里拿 `mcpmListView()` 的**打码视图**把模型没给的字段填回去,让 op 去还原。
|
|
19
|
+
// 反过来说,绝不能**省略**字段:`parseKv(undefined)` 返回 `{}`(不是 undefined),
|
|
20
|
+
// `mcpm-edit` 里那句 `if (row[field] === undefined) return` 就不会生效,于是
|
|
21
|
+
// `resolveMaskedKv({}, 旧值)` 把整份 headers / env 静默清空、连 warning 都不给。
|
|
3
22
|
import { DEFAULT_MCP_NOTE_MAX_LENGTH, normalizeMcpNote } from '../mcp/state-section.js';
|
|
4
23
|
import { text } from './deps.js';
|
|
24
|
+
/** 「去看清单」那句:`mcp_manager_list` 被关掉时不能点名它(调用方按 `toolVisible` 决定给不给)。 */
|
|
25
|
+
const MCP_LIST_HINT = '(用 mcp_manager_list 看清单,all=true 连停用的也列)';
|
|
26
|
+
/** 按 serverName 定位条目;同名(project + global 各一台)时要求 level 明确指定。 */
|
|
27
|
+
function locate(rows, server, level, listHint = MCP_LIST_HINT) {
|
|
28
|
+
const hits = (rows || []).filter((r) => String(r.serverName) === server);
|
|
29
|
+
if (!hits.length) {
|
|
30
|
+
throw new Error('没有这台服务器:' + server + listHint);
|
|
31
|
+
}
|
|
32
|
+
if (level) {
|
|
33
|
+
const picked = hits.filter((r) => r.level === level);
|
|
34
|
+
if (!picked.length) {
|
|
35
|
+
throw new Error('「' + server + '」没有 level=' + level + ' 的那一台(现有:' + hits.map((r) => r.level).join(' / ') + ')');
|
|
36
|
+
}
|
|
37
|
+
return picked[0];
|
|
38
|
+
}
|
|
39
|
+
if (hits.length > 1) {
|
|
40
|
+
throw new Error('「' + server + '」在 ' + hits.map((r) => r.level).join(' 与 ') + ' 各有一台,无法确定动哪一台 —— 传 level 指定');
|
|
41
|
+
}
|
|
42
|
+
return hits[0];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 打码过的键值映射 → `mcpm-edit` / `mcpm-add` 要的文本形态(每行 `K=V`)。
|
|
46
|
+
*
|
|
47
|
+
* 打码值原样带过去是**有意**的:`resolveMaskedKv` 会把 `••••••` 认成"这一项没改",
|
|
48
|
+
* 拿补丁文件里的真值顶替回来;非敏感键(`maskValueMap` 只打敏感键)本来就是明文,原样往返。
|
|
49
|
+
*/
|
|
50
|
+
function kvToText(map) {
|
|
51
|
+
if (!map || typeof map !== 'object')
|
|
52
|
+
return '';
|
|
53
|
+
return Object.keys(map).map((k) => k + '=' + String(map[k])).join('\n');
|
|
54
|
+
}
|
|
55
|
+
/** 行数组形态的 args → op 要的空格分隔文本(op 侧 `parseArgs` 再拆回来)。 */
|
|
56
|
+
function argsToText(value) {
|
|
57
|
+
return Array.isArray(value) ? value.join(' ') : String(value == null ? '' : value);
|
|
58
|
+
}
|
|
5
59
|
export function buildMcpTools(deps) {
|
|
6
60
|
const { defineTool, register } = deps;
|
|
61
|
+
/** 那句"去看清单"要不要给:`mcp_manager_list` 在出厂默认里就是关着的,点名一条模型没有的工具
|
|
62
|
+
* 只会让它白跑一趟(执行侧拦住它,而它看不出为什么)。 */
|
|
63
|
+
const listHint = () => (deps.toolVisible('mcp_manager_list') ? MCP_LIST_HINT : '');
|
|
7
64
|
register(defineTool({
|
|
8
65
|
name: 'mcp_manager_list',
|
|
9
|
-
|
|
66
|
+
// 注入段与本工具的关系要**如实说成筛过的视图**(而非"同一份清单"):注入段只列可用 +
|
|
67
|
+
// 曾连上现掉线的服务器(停用的不进),也不带 level / loader / 工具数。把这个盲区写出来
|
|
68
|
+
// 是行动依据 —— 模型要知道"停用的那台去哪查"就得靠这句(all=true)。
|
|
69
|
+
description: 'List configured MCP servers (level, enabled state, live loader status, tool count excluding switched-off tools, note). Read a server\'s note before choosing it. The「本机 MCP 服务器的当前状态」reminder carries a filtered view (usable servers, ones that were reachable before but are down now, and their notes; disabled servers are omitted); this tool is the raw view — defaults to enabled servers, all=true for every configured one.',
|
|
10
70
|
parameters: {
|
|
11
71
|
all: { type: 'boolean', description: 'Include disabled servers (default false).' },
|
|
72
|
+
tools: { type: 'boolean', description: 'Also list each server\'s tool names, marking the switched-off ones (default false).' },
|
|
12
73
|
},
|
|
13
74
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
14
75
|
async execute(args, exec) {
|
|
15
76
|
const showAll = Boolean(args && args.all === true);
|
|
77
|
+
const showTools = Boolean(args && args.tools === true);
|
|
16
78
|
// 必须走 mcpmListView()(= mcpmRowsWithNotes(false)):备注在这里合入,且未打码的
|
|
17
79
|
// url/headers/env 已被打码。备注是本插件 MCP 这一项的真正价值(用户写给未来模型的
|
|
18
80
|
// 决策提示,如「A 挂了改用 B」),而它只走注入通道 —— 压制型预设下不注入时,
|
|
@@ -24,16 +86,30 @@ export function buildMcpTools(deps) {
|
|
|
24
86
|
// 默认只列「已启用」的(用户裁定 2026-09-16,与技能 / 记忆列表同口径):停用的服务器
|
|
25
87
|
// 模型调不到,列出来只是噪音。表头给出全量口径,所以不会变成盲区。
|
|
26
88
|
const shown = showAll ? all : all.filter((x) => !x.disabled);
|
|
27
|
-
const
|
|
89
|
+
const lines = [];
|
|
90
|
+
for (const x of shown) {
|
|
28
91
|
const note = normalizeMcpNote(x.notes, DEFAULT_MCP_NOTE_MAX_LENGTH);
|
|
29
92
|
// 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
|
|
30
93
|
// 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
|
|
31
94
|
const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
|
|
32
|
-
|
|
95
|
+
lines.push(x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
|
|
33
96
|
(x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
|
|
34
97
|
(typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
|
|
35
|
-
(note ? ' | user-hint:' + note : '');
|
|
36
|
-
|
|
98
|
+
(note ? ' | user-hint:' + note : ''));
|
|
99
|
+
if (!showTools)
|
|
100
|
+
continue;
|
|
101
|
+
// 工具名单只在要的时候才去问:`mcpm-tools` 要读 schemas() + 停用表 + 「上次见过」缓存,
|
|
102
|
+
// 是这次列表里最贵的一步,而默认口径(只列服务器)根本用不上它。
|
|
103
|
+
// **不调 `mcpm-tools-refresh`**:那个会临时起进程(npx 冷启动可能几十秒),
|
|
104
|
+
// 那是界面上的显式动作,不该由一次列表调用顺手触发。
|
|
105
|
+
const t = await deps.mcpmTools({ serverName: String(x.serverName) });
|
|
106
|
+
const items = t && t.ok !== false ? (t.tools || []) : [];
|
|
107
|
+
if (!items.length) {
|
|
108
|
+
lines.push(' ' + (x.disabled ? '(tools not recorded: server is off)' : '(no tools recorded)'));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
lines.push(' tools: ' + items.map((it) => it.name + (it.enabled === false ? ' (off)' : '')).join(', '));
|
|
112
|
+
}
|
|
37
113
|
const header = 'MCP servers: ' + (showAll
|
|
38
114
|
? all.length + ' configured'
|
|
39
115
|
: shown.length + ' enabled of ' + all.length + ' configured' +
|
|
@@ -43,68 +119,233 @@ export function buildMcpTools(deps) {
|
|
|
43
119
|
// 模型会把「能调用这些工具」当成「已经知道有哪些 server、用户给它们写了什么备注」。
|
|
44
120
|
// 提示本身不点名任何工具,所以挂在哪个发现型工具上都不会出现循环指引。
|
|
45
121
|
const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
|
|
46
|
-
return header + (
|
|
122
|
+
return header + (lines.join('\n') || '(none)') + notice;
|
|
47
123
|
},
|
|
48
124
|
}));
|
|
49
125
|
register(defineTool({
|
|
50
|
-
name: '
|
|
51
|
-
|
|
126
|
+
name: 'mcp_manager_switch',
|
|
127
|
+
// 为什么 action 是必填枚举而不是两个可选布尔:模型最容易犯的错是"想重启却只传了 server",
|
|
128
|
+
// 让它在三个动词里选一个,比让它猜两个布尔的组合省一次往返。
|
|
129
|
+
description: 'Turn one configured MCP server on or off, or restart it (reconnects and re-syncs tools). Pass `tool` to switch just that tool. Only when the user asks or approves.',
|
|
52
130
|
parameters: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
131
|
+
server: { type: 'string', required: true, description: 'Server name as shown in the list.' },
|
|
132
|
+
action: { type: 'string', required: true, enum: ['on', 'off', 'restart'], description: 'on = enable, off = disable, restart = reconnect and re-sync tools.' },
|
|
133
|
+
level: { type: 'string', description: 'project or global — only to disambiguate same-name servers.' },
|
|
134
|
+
tool: { type: 'string', description: 'One tool name on that server (no mcp__ prefix). Only for on/off.' },
|
|
56
135
|
},
|
|
57
136
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
58
137
|
async execute(args) {
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
throw new Error(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
138
|
+
const action = String((args && args.action) || '');
|
|
139
|
+
if (action !== 'on' && action !== 'off' && action !== 'restart') {
|
|
140
|
+
throw new Error('action 只能是 on / off / restart(收到:' + (action || '(空)') + ')');
|
|
141
|
+
}
|
|
142
|
+
const server = String((args && args.server) || '').trim();
|
|
143
|
+
const tool = args && args.tool !== undefined ? String(args.tool).trim() : '';
|
|
144
|
+
if (tool !== '' && action === 'restart') {
|
|
145
|
+
throw new Error('重启是整台服务器的动作(先停用、等断开、再按原状态启用),不能只重启一个工具');
|
|
146
|
+
}
|
|
147
|
+
// on|off 会写补丁(`mcpm-set-enabled` / `mcpm-tool-enabled` 在登记表里是 frozen),
|
|
148
|
+
// 所以过场景冻结守卫;restart 不过 —— 它是锁定期间唯一的恢复入口(见文件头)。
|
|
149
|
+
if (action !== 'restart') {
|
|
150
|
+
const blocked = await deps.lockedSceneGuard();
|
|
151
|
+
if (blocked)
|
|
152
|
+
throw new Error(blocked);
|
|
153
|
+
}
|
|
154
|
+
const list = await deps.mcpmListView();
|
|
155
|
+
if (!list || list.ok === false)
|
|
156
|
+
throw new Error((list && list.error) || '读取 MCP 配置失败');
|
|
157
|
+
const level = args && args.level !== undefined ? String(args.level) : undefined;
|
|
158
|
+
const row = locate(list.rows || [], server, level, listHint());
|
|
159
|
+
const enabled = action === 'on';
|
|
160
|
+
if (tool !== '') {
|
|
161
|
+
const r = await deps.mcpmToolEnabled({ serverName: String(row.serverName), tool, enabled });
|
|
162
|
+
if (!r || r.ok === false)
|
|
163
|
+
throw new Error((r && r.error) || '切换工具启停失败');
|
|
164
|
+
// 与面板同一条同步:`mcpm-tool-enabled` 在登记表里带 syncsArchive,漏了就是
|
|
165
|
+
// "页面拨的进档案、模型拨的不进"。
|
|
166
|
+
const syncErr = await deps.syncSwitchToScene('mcpm-tool-enabled', { serverName: String(row.serverName), tool, enabled });
|
|
167
|
+
// 整台停用时,工具级开关在运行时本来就不生效(那个工具压根不存在)—— 不说的话
|
|
168
|
+
// 模型会以为已经生效。与记忆族"启用了一条不在启用场景里的记忆"同一条理由。
|
|
169
|
+
const offNote = row.disabled ? '(注意:整台服务器当前是停用的,这个工具级开关要等它启用才生效)' : '';
|
|
170
|
+
return 'OK: ' + String(row.serverName) + ' tool ' + tool + ' now ' + (enabled ? 'enabled' : 'disabled') + offNote +
|
|
171
|
+
(syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
|
|
172
|
+
}
|
|
173
|
+
if (action === 'restart') {
|
|
174
|
+
const r = await deps.mcpmRestart({ id: row.id, level: row.level });
|
|
175
|
+
if (!r || r.ok === false)
|
|
176
|
+
throw new Error((r && r.error) || '重启失败');
|
|
177
|
+
return 'OK: ' + String(row.serverName) + ' (' + row.level + ') restarted';
|
|
178
|
+
}
|
|
179
|
+
const r = await deps.mcpmSetEnabled({ id: row.id, level: row.level, enabled });
|
|
180
|
+
if (!r || r.ok === false)
|
|
181
|
+
throw new Error((r && r.error) || '切换服务器启停失败');
|
|
65
182
|
// 场景未锁定时,页面 / 模型改的开关都要落进当前场景的档案(与 handlers 层同一套同步)。
|
|
66
|
-
const syncErr = await deps.syncSwitchToScene('mcpm-set-enabled',
|
|
67
|
-
return 'OK: ' +
|
|
183
|
+
const syncErr = await deps.syncSwitchToScene('mcpm-set-enabled', { id: row.id, level: row.level, enabled });
|
|
184
|
+
return 'OK: ' + String(row.serverName) + ' (' + row.level + ') now ' + (enabled ? 'enabled' : 'disabled') +
|
|
68
185
|
(syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
|
|
69
186
|
},
|
|
70
187
|
}));
|
|
71
188
|
register(defineTool({
|
|
72
|
-
name: '
|
|
73
|
-
description: '
|
|
189
|
+
name: 'mcp_manager_save',
|
|
190
|
+
description: 'Add a new MCP server, or update the one with this name — the receipt says which. Omitted fields keep their current values, so never restate the whole server. Only on the user\'s explicit request.',
|
|
74
191
|
parameters: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
},
|
|
85
|
-
}));
|
|
86
|
-
register(defineTool({
|
|
87
|
-
name: 'mcp_manager_add',
|
|
88
|
-
description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
|
|
89
|
-
parameters: {
|
|
90
|
-
serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
|
|
91
|
-
transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
|
|
92
|
-
url: { type: 'string', description: 'Server URL (required for streamable-http).' },
|
|
93
|
-
command: { type: 'string', description: 'Executable (required for stdio).' },
|
|
94
|
-
args: { type: 'string', description: 'Arguments, space separated (stdio).' },
|
|
95
|
-
headers: { type: 'string', description: 'Extra headers as key=value lines (streamable-http).' },
|
|
96
|
-
env: { type: 'string', description: 'Extra env vars as key=value lines (stdio).' },
|
|
97
|
-
level: { type: 'string', description: 'project or global (default project).' },
|
|
192
|
+
server: { type: 'string', required: true, description: 'Server name (1-32 chars, [A-Za-z0-9_-]) — the name the mcp__<server>__ tools use.' },
|
|
193
|
+
transport: { type: 'string', description: 'streamable-http or stdio. Required when creating; omit = keep.' },
|
|
194
|
+
url: { type: 'string', description: 'Server URL (streamable-http). Omit = keep.' },
|
|
195
|
+
command: { type: 'string', description: 'Executable to spawn (stdio). Omit = keep.' },
|
|
196
|
+
args: { type: 'string', description: 'Arguments, space separated (stdio). Omit = keep.' },
|
|
197
|
+
headers: { type: 'string', description: 'Extra headers, key=value per line (streamable-http). Omit = keep — omitting is what preserves the real secrets.' },
|
|
198
|
+
env: { type: 'string', description: 'Extra env vars, key=value per line (stdio). Omit = keep.' },
|
|
199
|
+
level: { type: 'string', description: 'project or global (project when creating); only needed for same-name servers.' },
|
|
200
|
+
note: { type: 'string', description: 'Short hint for later: how to use it, or which one to fall back to when it is down.' },
|
|
98
201
|
},
|
|
99
202
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
100
203
|
async execute(args) {
|
|
101
204
|
const blocked = await deps.lockedSceneGuard();
|
|
102
205
|
if (blocked)
|
|
103
206
|
throw new Error(blocked);
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
106
|
-
throw new Error(
|
|
107
|
-
|
|
207
|
+
const server = String((args && args.server) || '').trim();
|
|
208
|
+
if (!server)
|
|
209
|
+
throw new Error('缺少参数:server(服务器名)');
|
|
210
|
+
const levelArg = args && args.level !== undefined ? String(args.level) : undefined;
|
|
211
|
+
const noteText = args && args.note !== undefined ? String(args.note) : '';
|
|
212
|
+
const hasNote = noteText.trim() !== '';
|
|
213
|
+
const list = await deps.mcpmListView();
|
|
214
|
+
if (!list || list.ok === false)
|
|
215
|
+
throw new Error((list && list.error) || '读取 MCP 配置失败');
|
|
216
|
+
const rows = list.rows || [];
|
|
217
|
+
const hits = rows.filter((r) => String(r.serverName) === server);
|
|
218
|
+
// ── 建:transport 与它那一侧的必填项都得给 ─────────────────────────────
|
|
219
|
+
if (!hits.length) {
|
|
220
|
+
const transport = String((args && args.transport) || '').trim();
|
|
221
|
+
if (transport !== 'stdio' && transport !== 'streamable-http') {
|
|
222
|
+
throw new Error('新建服务器必须给 transport:stdio 或 streamable-http');
|
|
223
|
+
}
|
|
224
|
+
if (transport === 'stdio' && String((args && args.command) || '').trim() === '') {
|
|
225
|
+
throw new Error('新建 stdio 服务器必须给 command(宿主会按它 spawn 进程)');
|
|
226
|
+
}
|
|
227
|
+
if (transport === 'streamable-http' && String((args && args.url) || '').trim() === '') {
|
|
228
|
+
throw new Error('新建 streamable-http 服务器必须给 url');
|
|
229
|
+
}
|
|
230
|
+
const payload = {
|
|
231
|
+
serverName: server,
|
|
232
|
+
transport,
|
|
233
|
+
url: args.url,
|
|
234
|
+
command: args.command,
|
|
235
|
+
args: args.args,
|
|
236
|
+
headers: args.headers,
|
|
237
|
+
env: args.env,
|
|
238
|
+
};
|
|
239
|
+
if (levelArg)
|
|
240
|
+
payload.level = levelArg;
|
|
241
|
+
const r = await deps.mcpmAdd(payload);
|
|
242
|
+
if (!r || r.ok === false)
|
|
243
|
+
throw new Error((r && r.error) || '新增服务器失败');
|
|
244
|
+
// 备注单独走 `mcpm-note`(`mcpm-add` 不认这个字段)。失败**不回滚**主写入:
|
|
245
|
+
// 服务器已经建好了,"备注没写上"是 warning,不是失败(与 0.13.0 界面同规格)。
|
|
246
|
+
let warn = '';
|
|
247
|
+
if (hasNote) {
|
|
248
|
+
const n = await deps.mcpmNote({ id: String(r.row.id), note: noteText });
|
|
249
|
+
if (!n || n.ok === false)
|
|
250
|
+
warn = '\nWARN: 服务器已建好,但备注没写进去(' + ((n && n.error) || '未知原因') + ')';
|
|
251
|
+
}
|
|
252
|
+
return 'OK: created ' + r.row.id + ' at ' + r.row.level + warn;
|
|
253
|
+
}
|
|
254
|
+
// ── 改:省略 = 保持,且必须**显式填回**(见文件头那条 parseKv 的坑)────────
|
|
255
|
+
const cur = locate(hits, server, levelArg, listHint());
|
|
256
|
+
const transport = args && args.transport !== undefined ? String(args.transport) : String(cur.transport);
|
|
257
|
+
if (transport !== 'stdio' && transport !== 'streamable-http') {
|
|
258
|
+
throw new Error('transport 只能是 stdio 或 streamable-http(这台现在是:' + String(cur.transport) + ')');
|
|
259
|
+
}
|
|
260
|
+
const touched = [];
|
|
261
|
+
const ignored = [];
|
|
262
|
+
if (args.transport !== undefined)
|
|
263
|
+
touched.push('transport');
|
|
264
|
+
const payload = { id: cur.id, level: cur.level, serverName: String(cur.serverName), transport };
|
|
265
|
+
if (transport === 'streamable-http') {
|
|
266
|
+
// url 与 headers 都按**打码形态**填回:`resolveMaskedUrl` / `resolveMaskedKv` 会把它们
|
|
267
|
+
// 还原成补丁文件里的真值。这样"只改 headers"不会把 url 的查询串抹掉,也不会把
|
|
268
|
+
// 没重述的密钥写成 `••••••`。
|
|
269
|
+
if (args.url !== undefined) {
|
|
270
|
+
payload.url = String(args.url);
|
|
271
|
+
touched.push('url');
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
payload.url = String(cur.url == null ? '' : cur.url);
|
|
275
|
+
}
|
|
276
|
+
if (args.headers !== undefined) {
|
|
277
|
+
payload.headers = String(args.headers);
|
|
278
|
+
touched.push('headers');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
payload.headers = kvToText(cur.headers);
|
|
282
|
+
}
|
|
283
|
+
// 与当前传输方式无关的字段:**说出来**而不是静默丢掉(静默丢输入是"看起来成功")。
|
|
284
|
+
// 逐项写而不是循环:参数表的类型由宿主签名推断,动态键索引会退化成 any(deps.ts 里
|
|
285
|
+
// 特意保住的正是这份推断)。
|
|
286
|
+
if (args.command !== undefined)
|
|
287
|
+
ignored.push('command');
|
|
288
|
+
if (args.args !== undefined)
|
|
289
|
+
ignored.push('args');
|
|
290
|
+
if (args.env !== undefined)
|
|
291
|
+
ignored.push('env');
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
if (args.command !== undefined) {
|
|
295
|
+
payload.command = String(args.command);
|
|
296
|
+
touched.push('command');
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
payload.command = String(cur.command == null ? '' : cur.command);
|
|
300
|
+
}
|
|
301
|
+
if (args.args !== undefined) {
|
|
302
|
+
payload.args = String(args.args);
|
|
303
|
+
touched.push('args');
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
payload.args = argsToText(cur.args);
|
|
307
|
+
}
|
|
308
|
+
if (args.env !== undefined) {
|
|
309
|
+
payload.env = String(args.env);
|
|
310
|
+
touched.push('env');
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
payload.env = kvToText(cur.env);
|
|
314
|
+
}
|
|
315
|
+
if (args.url !== undefined)
|
|
316
|
+
ignored.push('url');
|
|
317
|
+
if (args.headers !== undefined)
|
|
318
|
+
ignored.push('headers');
|
|
319
|
+
}
|
|
320
|
+
const levelChanged = levelArg !== undefined && levelArg !== cur.level;
|
|
321
|
+
// 只给了备注:不必走 `mcpm-edit` 把整条重建一遍(那是一次无意义的写盘 + 刷新 updatedAt),
|
|
322
|
+
// 直接写备注。这与"三个可改字段一个都没给就拒"是同一条理由的两面。
|
|
323
|
+
if (!touched.length && !levelChanged) {
|
|
324
|
+
if (!hasNote)
|
|
325
|
+
throw new Error('没有要改的东西:transport / url / command / args / headers / env / level / note 至少给一个');
|
|
326
|
+
const n = await deps.mcpmNote({ id: cur.id, note: noteText });
|
|
327
|
+
if (!n || n.ok === false)
|
|
328
|
+
throw new Error((n && n.error) || '写备注失败');
|
|
329
|
+
return 'OK: updated ' + String(cur.serverName) + '(改了 note)';
|
|
330
|
+
}
|
|
331
|
+
const r = await deps.mcpmEdit(payload);
|
|
332
|
+
if (!r || r.ok === false)
|
|
333
|
+
throw new Error((r && r.error) || '修改服务器失败');
|
|
334
|
+
const changed = touched.concat(levelChanged ? ['level'] : [], hasNote ? ['note'] : []);
|
|
335
|
+
let warn = '';
|
|
336
|
+
// `mcpm-edit` 的守卫会把"打码值被顶替回真值"这件事回报出来(只含键名,不含值)——
|
|
337
|
+
// 转给模型,它才知道"我没重述的密钥确实保住了"。
|
|
338
|
+
if (r.warning)
|
|
339
|
+
warn += '\nNOTE: ' + String(r.warning);
|
|
340
|
+
if (hasNote) {
|
|
341
|
+
const n = await deps.mcpmNote({ id: cur.id, note: noteText });
|
|
342
|
+
if (!n || n.ok === false)
|
|
343
|
+
warn += '\nWARN: 服务器已保存,但备注没写进去(' + ((n && n.error) || '未知原因') + ')';
|
|
344
|
+
}
|
|
345
|
+
if (ignored.length) {
|
|
346
|
+
warn += '\nWARN: ' + ignored.join(' / ') + ' 与当前 transport(' + transport + ')无关,已忽略';
|
|
347
|
+
}
|
|
348
|
+
return 'OK: updated ' + String(cur.serverName) + '(改了 ' + changed.join('、') + ')' + warn;
|
|
108
349
|
},
|
|
109
350
|
}));
|
|
110
351
|
}
|