dsh-plugin-tool-management 0.13.0 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +91 -123
  3. package/README_EN.md +99 -126
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +28 -0
  21. package/lib/client.js +578 -124
  22. package/lib/compat/preset-reach.js +6 -3
  23. package/lib/context-inject.js +86 -32
  24. package/lib/index.js +251 -92
  25. package/lib/mcp/manager.js +3 -14
  26. package/lib/mcp/secret-guard.js +21 -0
  27. package/lib/mcp/state-section.js +13 -11
  28. package/lib/memories/constants.js +72 -8
  29. package/lib/memories/index-io.js +1 -1
  30. package/lib/memories/projection.js +67 -31
  31. package/lib/memories/service.js +17 -2
  32. package/lib/memories/snapshot.js +113 -22
  33. package/lib/op-registry.js +8 -2
  34. package/lib/ops/candidates.js +11 -0
  35. package/lib/ops/compat.js +10 -2
  36. package/lib/ops/sessions.js +1 -1
  37. package/lib/prompts/service.js +25 -2
  38. package/lib/scenes/candidates.js +56 -0
  39. package/lib/skills/core.js +93 -0
  40. package/lib/skills/service.js +34 -2
  41. package/lib/subagents/service.js +37 -4
  42. package/lib/subagents/tools.js +11 -4
  43. package/lib/tools/deps.js +15 -0
  44. package/lib/tools/mcp.js +291 -50
  45. package/lib/tools/memory.js +131 -80
  46. package/lib/tools/prompt.js +27 -13
  47. package/lib/tools/scene.js +351 -0
  48. package/lib/tools/skills.js +93 -17
  49. package/lib/tools/subagent.js +56 -45
  50. package/lib/tools/table.js +204 -15
  51. package/package.json +108 -108
package/lib/tools/mcp.js CHANGED
@@ -1,18 +1,80 @@
1
- // MCP 域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
- // mcp_manager_list / mcp_manager_set_enabled / mcp_manager_restart / mcp_manager_add。
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
- 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 same list is injected into your context each turn (the「本机 MCP 服务器的当前状态」system-reminder); this tool is the raw view — all=true includes disabled servers. Defaults to enabled servers; pass all=true for every configured server.',
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 summary = shown.map((x) => {
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
- return x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
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 + (summary.join('\n') || '(none)') + notice;
122
+ return header + (lines.join('\n') || '(none)') + notice;
47
123
  },
48
124
  }));
49
125
  register(defineTool({
50
- name: 'mcp_manager_set_enabled',
51
- description: 'Enable or disable one configured MCP server (writes the patch file; hot-reloaded). Only when the user asks or approves.',
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
- id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
54
- level: { type: 'string', required: true, description: 'project or global.' },
55
- enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
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 blocked = await deps.lockedSceneGuard();
60
- if (blocked)
61
- throw new Error(blocked);
62
- const r = await deps.mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
63
- if (!r.ok)
64
- throw new Error(r.error);
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', args);
67
- return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
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: 'mcp_manager_restart',
73
- description: 'Restart one configured MCP server (disable + re-enable; reconnects and re-syncs tools). Only when the user asks or approves.',
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
- id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
76
- level: { type: 'string', required: true, description: 'project or global.' },
77
- },
78
- output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
79
- async execute(args) {
80
- const r = await deps.mcpmRestart({ id: args.id, level: args.level });
81
- if (!r.ok)
82
- throw new Error(r.error);
83
- return 'OK: ' + args.id + ' restarted';
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. Only on the user\'s explicit request.',
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 r = await deps.mcpmAdd(args);
105
- if (!r.ok)
106
- throw new Error(r.error);
107
- return 'OK: added ' + r.row.id + ' at ' + r.row.level;
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
  }