@huanlin/dsh-plugin-mcp-manager 0.1.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/README.md +131 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +623 -0
- package/lib/index.mjs +812 -0
- package/package.json +72 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { YAMLMap, YAMLSeq, parseDocument } from "yaml";
|
|
5
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
+
//#region src/registry.ts
|
|
7
|
+
/**
|
|
8
|
+
* MCP 服务器注册表:读写 profile `cordis.patch.yml` 中
|
|
9
|
+
* `name: '@deepseek-ai/dsh-mcp-client'` 的 insert 行。
|
|
10
|
+
*
|
|
11
|
+
* 单一事实来源与官方配置形态一致——每行 = 一个 mcp-client 插件实例,
|
|
12
|
+
* 配置 HMR 实时挂载/卸载/热替换,连接生命周期完全委托官方 mcp-client。
|
|
13
|
+
*
|
|
14
|
+
* 实现要点(对照开发计划 §3):
|
|
15
|
+
* - 用 eemeli `yaml` 的 Document API(parseDocument → 改节点 → toString)
|
|
16
|
+
* 而非行级字符串拼接:config 嵌套深,且需保留 `!!js` 表达式与其他行的
|
|
17
|
+
* 注释/结构(已实证 eemeli yaml 往返保留 !!js + 注释,js-yaml 默认 schema
|
|
18
|
+
* 拒绝 !!js)。
|
|
19
|
+
* - 写前用官方 `loadOverlayPatches`(@deepseek-ai/dsh-app-boot)校验可解析,
|
|
20
|
+
* 失败则拒绝写入(防写坏用户配置导致 web 启动失败)。
|
|
21
|
+
* - serverName 全 profile 唯一(mcp-client 在加载时拒绝重复 serverName)。
|
|
22
|
+
* - 编辑既有行:整块替换 config(不深合并),与 loader patch 语义一致。
|
|
23
|
+
* - 删除行:移除 insert 块中该 `- id:` 子树;块空则删块。
|
|
24
|
+
*
|
|
25
|
+
* 零源码 patch:只读写 profile 的用户 patch 层(官方 HMR-watched 文件)。
|
|
26
|
+
*/
|
|
27
|
+
/** mcp-client insert 行的 name 字段(官方包名,Loader 从 node_modules 解析)。 */
|
|
28
|
+
const MCP_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
|
29
|
+
/** serverName 合法字符(与官方 mcp-client SERVER_NAME_PATTERN 一致)。 */
|
|
30
|
+
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
31
|
+
/** 解析 DSH_HOME(官方 dsh-paths 语义)。 */
|
|
32
|
+
function resolveDshHome() {
|
|
33
|
+
return process.env.DSH_HOME?.trim() !== "" && process.env.DSH_HOME !== void 0 ? process.env.DSH_HOME : join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".dsh");
|
|
34
|
+
}
|
|
35
|
+
/** 当前 profile(web 默认)目录。 */
|
|
36
|
+
function profileWebDir() {
|
|
37
|
+
return join(resolveDshHome(), "profiles", "web");
|
|
38
|
+
}
|
|
39
|
+
/** 当前 profile 的 cordis.patch.yml(用户 patch 层,配置 HMR watched)。 */
|
|
40
|
+
function profilePatchPath() {
|
|
41
|
+
return join(profileWebDir(), "cordis.patch.yml");
|
|
42
|
+
}
|
|
43
|
+
/** 校验错误(携带字段名便于 UI 定位)。 */
|
|
44
|
+
var RegistryError = class extends Error {
|
|
45
|
+
field;
|
|
46
|
+
constructor(field, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.field = field;
|
|
49
|
+
this.name = "RegistryError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* 校验一个服务器配置。fail loud:非法值抛 RegistryError(携带字段名)。
|
|
54
|
+
* 与官方 mcp-client Config schema 语义对齐,但不依赖其运行时(外部插件
|
|
55
|
+
* 不能 import mcp-client src;契约以 README 为准)。
|
|
56
|
+
*/
|
|
57
|
+
function validateServerConfig(config) {
|
|
58
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) throw new RegistryError("config", "server config must be an object");
|
|
59
|
+
const c = config;
|
|
60
|
+
if (typeof c.serverName !== "string" || !SERVER_NAME_PATTERN.test(c.serverName)) throw new RegistryError("serverName", `serverName must match ${SERVER_NAME_PATTERN.source}`);
|
|
61
|
+
if (c.transport !== "stdio" && c.transport !== "streamable-http") throw new RegistryError("transport", "transport must be 'stdio' or 'streamable-http'");
|
|
62
|
+
if (c.transport === "stdio") {
|
|
63
|
+
if (typeof c.command !== "string" || c.command.length === 0) throw new RegistryError("command", "stdio transport requires a non-empty command");
|
|
64
|
+
if (c.args !== void 0 && (!Array.isArray(c.args) || c.args.some((a) => typeof a !== "string"))) throw new RegistryError("args", "args must be an array of strings");
|
|
65
|
+
if (c.env !== void 0 && (typeof c.env !== "object" || c.env === null || Object.values(c.env).some((v) => typeof v !== "string"))) throw new RegistryError("env", "env must be a string→string map");
|
|
66
|
+
if (c.cwd !== void 0 && typeof c.cwd !== "string") throw new RegistryError("cwd", "cwd must be a string");
|
|
67
|
+
} else {
|
|
68
|
+
if (typeof c.url !== "string" || c.url.length === 0) throw new RegistryError("url", "streamable-http transport requires a non-empty url");
|
|
69
|
+
if (c.headers !== void 0 && (typeof c.headers !== "object" || c.headers === null || Object.values(c.headers).some((v) => typeof v !== "string"))) throw new RegistryError("headers", "headers must be a string→string map");
|
|
70
|
+
}
|
|
71
|
+
if (c.toolCallTimeoutMs !== void 0 && (typeof c.toolCallTimeoutMs !== "number" || c.toolCallTimeoutMs < 1)) throw new RegistryError("toolCallTimeoutMs", "toolCallTimeoutMs must be a positive number");
|
|
72
|
+
if (c.failOnStartupError !== void 0 && typeof c.failOnStartupError !== "boolean") throw new RegistryError("failOnStartupError", "failOnStartupError must be a boolean");
|
|
73
|
+
if (c.reconnect !== void 0) {
|
|
74
|
+
if (typeof c.reconnect !== "object" || c.reconnect === null) throw new RegistryError("reconnect", "reconnect must be an object");
|
|
75
|
+
const r = c.reconnect;
|
|
76
|
+
if (r.enabled !== void 0 && typeof r.enabled !== "boolean") throw new RegistryError("reconnect.enabled", "reconnect.enabled must be a boolean");
|
|
77
|
+
for (const key of [
|
|
78
|
+
"initialDelayMs",
|
|
79
|
+
"maxDelayMs",
|
|
80
|
+
"maxAttempts"
|
|
81
|
+
]) if (r[key] !== void 0 && (typeof r[key] !== "number" || r[key] < 1)) throw new RegistryError(`reconnect.${key}`, `reconnect.${key} must be a positive number`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** 生成 insert 行 id:mcp-<serverName>(serverName 已校验合法)。 */
|
|
85
|
+
function rowIdFor(serverName) {
|
|
86
|
+
return `mcp-${serverName}`;
|
|
87
|
+
}
|
|
88
|
+
/** 一个新的空列表文档(每次调用返回新实例,避免共享可变状态)。 */
|
|
89
|
+
function emptyPatchDocument() {
|
|
90
|
+
return parseDocument("[]\n");
|
|
91
|
+
}
|
|
92
|
+
/** 读取 patch 文件的 Document(保留注释/!!js);文件不存在返回空文档。 */
|
|
93
|
+
function readPatchDocument() {
|
|
94
|
+
const file = profilePatchPath();
|
|
95
|
+
let content;
|
|
96
|
+
try {
|
|
97
|
+
content = readFileSync(file, "utf8");
|
|
98
|
+
} catch {
|
|
99
|
+
return emptyPatchDocument();
|
|
100
|
+
}
|
|
101
|
+
const doc = parseDocument(content);
|
|
102
|
+
if (!(doc.contents instanceof YAMLSeq)) return emptyPatchDocument();
|
|
103
|
+
return doc;
|
|
104
|
+
}
|
|
105
|
+
/** 收集所有 insert 块中的 mcp-client 行(跨多个 insert 块,容错)。 */
|
|
106
|
+
function collectMcpRows(doc) {
|
|
107
|
+
const out = [];
|
|
108
|
+
const seq = doc.contents;
|
|
109
|
+
for (const entry of seq.items) {
|
|
110
|
+
if (!(entry instanceof YAMLMap)) continue;
|
|
111
|
+
const insertNode = entry.get("insert", true);
|
|
112
|
+
if (!(insertNode instanceof YAMLSeq)) continue;
|
|
113
|
+
const block = insertNode;
|
|
114
|
+
for (let i = 0; i < block.items.length; i += 1) {
|
|
115
|
+
const row = block.items[i];
|
|
116
|
+
if (!(row instanceof YAMLMap)) continue;
|
|
117
|
+
if (row.get("name") !== "@deepseek-ai/dsh-mcp-client") continue;
|
|
118
|
+
const id = typeof row.get("id") === "string" ? row.get("id") : "";
|
|
119
|
+
out.push({
|
|
120
|
+
block,
|
|
121
|
+
index: i,
|
|
122
|
+
row,
|
|
123
|
+
id
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** 将 McpServerConfig 转为纯 YAML 数据(用于 set 创建节点)。 */
|
|
130
|
+
function configToData(config) {
|
|
131
|
+
return { ...config };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 读全部 MCP 服务器行。配置块缺失/结构异常的行被跳过(不抛——读路径容忍)。
|
|
135
|
+
* 返回每行的 id 与解析出的 config(仅含存在的字段)。
|
|
136
|
+
*/
|
|
137
|
+
function listServers() {
|
|
138
|
+
const doc = readPatchDocument();
|
|
139
|
+
const rows = collectMcpRows(doc);
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const { row, id } of rows) {
|
|
142
|
+
const cfgNode = row.get("config", true);
|
|
143
|
+
if (!(cfgNode instanceof YAMLMap)) continue;
|
|
144
|
+
let config;
|
|
145
|
+
try {
|
|
146
|
+
config = cfgNode.toJS(doc);
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
out.push({
|
|
151
|
+
id,
|
|
152
|
+
name: MCP_CLIENT_PACKAGE,
|
|
153
|
+
config
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/** 写回 patch 文件并校验:可选 app-boot 校验器在场则用之,否则仅保证可解析。 */
|
|
159
|
+
function writeAndValidate(doc, loadOverlayPatches) {
|
|
160
|
+
const file = profilePatchPath();
|
|
161
|
+
const text = doc.toString();
|
|
162
|
+
let previous = null;
|
|
163
|
+
try {
|
|
164
|
+
previous = readFileSync(file, "utf8");
|
|
165
|
+
} catch {
|
|
166
|
+
previous = null;
|
|
167
|
+
}
|
|
168
|
+
writeFileSync(file, text);
|
|
169
|
+
if (loadOverlayPatches !== void 0) try {
|
|
170
|
+
loadOverlayPatches("dsh-mcp-manager", file);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (previous !== null) writeFileSync(file, previous);
|
|
173
|
+
throw new RegistryError("patch", `写入后校验失败(已回滚): ${error instanceof Error ? error.message : String(error)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 尝试动态解析官方 app-boot 的 loadOverlayPatches(可选 peer)。
|
|
178
|
+
* 未安装/不可导入时返回 undefined(降级为仅可解析检查——eemeli yaml 序列化
|
|
179
|
+
* 保证输出是合法 YAML 数组,写坏风险已极低)。
|
|
180
|
+
*/
|
|
181
|
+
function resolveLoadOverlayPatches() {
|
|
182
|
+
try {
|
|
183
|
+
const mod = createRequire(import.meta.url)("@deepseek-ai/dsh-app-boot");
|
|
184
|
+
return typeof mod.loadOverlayPatches === "function" ? mod.loadOverlayPatches : void 0;
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 新增一个 MCP 服务器。校验配置 + serverName 唯一 → 写 insert 行。
|
|
191
|
+
* @param config 服务器配置(纯数据)。
|
|
192
|
+
* @param id 可选行 id;默认 mcp-<serverName>。
|
|
193
|
+
* @param loadOverlayPatches 可选官方校验器(app-boot 提供)。
|
|
194
|
+
* @returns 新增行的 id。
|
|
195
|
+
* @throws RegistryError 配置非法 / serverName 重复 / id 重复 / 写后校验失败。
|
|
196
|
+
*/
|
|
197
|
+
function addServer(config, opts) {
|
|
198
|
+
validateServerConfig(config);
|
|
199
|
+
const id = (opts?.id ?? rowIdFor(config.serverName)).trim();
|
|
200
|
+
if (id.length === 0) throw new RegistryError("id", "id must be non-empty");
|
|
201
|
+
const doc = readPatchDocument();
|
|
202
|
+
const existing = collectMcpRows(doc);
|
|
203
|
+
if (existing.some((r) => {
|
|
204
|
+
const cfg = r.row.get("config", true);
|
|
205
|
+
if (!(cfg instanceof YAMLMap)) return false;
|
|
206
|
+
return cfg.get("serverName") === config.serverName;
|
|
207
|
+
})) throw new RegistryError("serverName", `serverName "${config.serverName}" is already in use`);
|
|
208
|
+
if (existing.some((r) => r.id === id)) throw new RegistryError("id", `id "${id}" is already in use`);
|
|
209
|
+
const seq = doc.contents;
|
|
210
|
+
let targetBlock;
|
|
211
|
+
for (const entry of seq.items) {
|
|
212
|
+
if (!(entry instanceof YAMLMap)) continue;
|
|
213
|
+
const insertNode = entry.get("insert", true);
|
|
214
|
+
if (insertNode instanceof YAMLSeq) {
|
|
215
|
+
targetBlock = insertNode;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (targetBlock === void 0) {
|
|
220
|
+
const insertEntry = new YAMLMap();
|
|
221
|
+
const newBlock = new YAMLSeq();
|
|
222
|
+
insertEntry.set("insert", newBlock);
|
|
223
|
+
seq.add(insertEntry);
|
|
224
|
+
targetBlock = newBlock;
|
|
225
|
+
}
|
|
226
|
+
const row = new YAMLMap();
|
|
227
|
+
row.set("id", id);
|
|
228
|
+
row.set("name", MCP_CLIENT_PACKAGE);
|
|
229
|
+
row.set("config", doc.createNode(configToData(config)));
|
|
230
|
+
targetBlock.add(row);
|
|
231
|
+
writeAndValidate(doc, opts?.loadOverlayPatches);
|
|
232
|
+
console.log(`[dsh-mcp-manager] added server ${id} (serverName=${config.serverName})`);
|
|
233
|
+
return id;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* 更新一个服务器(整块替换 config)。serverName 可改(需保持唯一)。
|
|
237
|
+
* @throws RegistryError 配置非法 / id 不存在 / serverName 与他人冲突 / 写后校验失败。
|
|
238
|
+
*/
|
|
239
|
+
function updateServer(id, config, opts) {
|
|
240
|
+
validateServerConfig(config);
|
|
241
|
+
const doc = readPatchDocument();
|
|
242
|
+
const rows = collectMcpRows(doc);
|
|
243
|
+
const target = rows.find((r) => r.id === id);
|
|
244
|
+
if (target === void 0) throw new RegistryError("id", `server "${id}" not found`);
|
|
245
|
+
for (const r of rows) {
|
|
246
|
+
if (r.id === id) continue;
|
|
247
|
+
const cfg = r.row.get("config", true);
|
|
248
|
+
if (!(cfg instanceof YAMLMap)) continue;
|
|
249
|
+
if (cfg.get("serverName") === config.serverName) throw new RegistryError("serverName", `serverName "${config.serverName}" is already in use by "${r.id}"`);
|
|
250
|
+
}
|
|
251
|
+
target.row.set("config", doc.createNode(configToData(config)));
|
|
252
|
+
writeAndValidate(doc, opts?.loadOverlayPatches);
|
|
253
|
+
console.log(`[dsh-mcp-manager] updated server ${id}`);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 删除一个服务器行。空掉的 insert 块一并删除(空 insert 是脏 patch)。
|
|
257
|
+
* @returns true 删除成功;false 行不存在。
|
|
258
|
+
*/
|
|
259
|
+
function deleteServer(id, opts) {
|
|
260
|
+
const doc = readPatchDocument();
|
|
261
|
+
const target = collectMcpRows(doc).find((r) => r.id === id);
|
|
262
|
+
if (target === void 0) return false;
|
|
263
|
+
target.block.delete(target.index);
|
|
264
|
+
if (target.block.items.length === 0) {
|
|
265
|
+
const seq = doc.contents;
|
|
266
|
+
for (let i = 0; i < seq.items.length; i += 1) {
|
|
267
|
+
const entry = seq.items[i];
|
|
268
|
+
if (!(entry instanceof YAMLMap)) continue;
|
|
269
|
+
const insertNode = entry.get("insert", true);
|
|
270
|
+
if (insertNode instanceof YAMLSeq && insertNode === target.block) {
|
|
271
|
+
seq.delete(i);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
writeAndValidate(doc, opts?.loadOverlayPatches);
|
|
277
|
+
console.log(`[dsh-mcp-manager] deleted server ${id}`);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/tools.ts
|
|
282
|
+
/**
|
|
283
|
+
* MCP 管理工具(mcp_* ×4):agent 面的服务器注册表管理(对齐开发计划 §M4)。
|
|
284
|
+
* 与 GUI 面板写同一安装态(profile cordis.patch.yml 的 mcp-client insert 行),
|
|
285
|
+
* 配置 HMR 实时挂载——agent 调用后工具立即可用(若服务器连接成功)。
|
|
286
|
+
*
|
|
287
|
+
* - mcp_server_list:列出全部 MCP 服务器 + 每个的已注册工具数(运行态)
|
|
288
|
+
* - mcp_server_add:新增服务器(校验 + 写 insert 行 → HMR 挂载 mcp-client 实例)
|
|
289
|
+
* - mcp_server_update:整块替换 config(serverName 不变则工具名不变)
|
|
290
|
+
* - mcp_server_remove:移除行 → 工具随实例 dispose 注销
|
|
291
|
+
*
|
|
292
|
+
* 依赖注入(deps):避免与 index.ts 循环依赖。连接生命周期完全委托官方
|
|
293
|
+
* mcp-client——管理插件只写配置,不拉连接。
|
|
294
|
+
*/
|
|
295
|
+
/** 把注册表行 + 运行态工具名投影为 agent 可见的规范视图。 */
|
|
296
|
+
function toServerView(row, toolNames, connectingSince) {
|
|
297
|
+
const prefix = `mcp__${row.config.serverName}__`;
|
|
298
|
+
const toolCount = toolNames.filter((n) => n.startsWith(prefix)).length;
|
|
299
|
+
const endpoint = row.config.transport === "stdio" ? `${row.config.command ?? ""} ${(row.config.args ?? []).join(" ")}`.trim() : row.config.url ?? "";
|
|
300
|
+
let status;
|
|
301
|
+
if (toolCount > 0) status = "connected";
|
|
302
|
+
else {
|
|
303
|
+
const since = connectingSince.get(row.config.serverName);
|
|
304
|
+
status = since !== void 0 && Date.now() - since < 3e4 ? "connecting" : "disconnected";
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
id: row.id,
|
|
308
|
+
serverName: row.config.serverName,
|
|
309
|
+
transport: row.config.transport,
|
|
310
|
+
endpoint,
|
|
311
|
+
toolCount,
|
|
312
|
+
status
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function renderServers(_args, value) {
|
|
316
|
+
if (value.servers.length === 0) return [{
|
|
317
|
+
type: "text",
|
|
318
|
+
text: "(no MCP servers registered)"
|
|
319
|
+
}];
|
|
320
|
+
return [{
|
|
321
|
+
type: "text",
|
|
322
|
+
text: value.servers.map((s) => `- ${s.id} [${s.status}] ${s.transport} · ${s.serverName} · ${s.toolCount} tool(s) · ${s.endpoint}`).join("\n")
|
|
323
|
+
}];
|
|
324
|
+
}
|
|
325
|
+
/** 共享参数 schema(add/update 用同一组字段描述)。 */
|
|
326
|
+
const SERVER_PARAMS = {
|
|
327
|
+
serverName: {
|
|
328
|
+
type: "string",
|
|
329
|
+
required: true,
|
|
330
|
+
description: "Stable namespace for tool names (mcp__<serverName>__*). Must match [A-Za-z0-9_-]{1,32} and be unique."
|
|
331
|
+
},
|
|
332
|
+
transport: {
|
|
333
|
+
type: "string",
|
|
334
|
+
required: true,
|
|
335
|
+
description: "Transport: 'stdio' (spawned child process) or 'streamable-http' (SSE)."
|
|
336
|
+
},
|
|
337
|
+
command: {
|
|
338
|
+
type: "string",
|
|
339
|
+
description: "stdio: executable to start the server."
|
|
340
|
+
},
|
|
341
|
+
args: {
|
|
342
|
+
type: "array",
|
|
343
|
+
items: { type: "string" },
|
|
344
|
+
description: "stdio: arguments passed to the command."
|
|
345
|
+
},
|
|
346
|
+
env: {
|
|
347
|
+
type: "object",
|
|
348
|
+
additionalProperties: true,
|
|
349
|
+
description: "stdio: extra env vars (string→string). WARNING: stored in plaintext in the profile patch file."
|
|
350
|
+
},
|
|
351
|
+
cwd: {
|
|
352
|
+
type: "string",
|
|
353
|
+
description: "stdio: working directory."
|
|
354
|
+
},
|
|
355
|
+
url: {
|
|
356
|
+
type: "string",
|
|
357
|
+
description: "streamable-http: MCP endpoint URL."
|
|
358
|
+
},
|
|
359
|
+
headers: {
|
|
360
|
+
type: "object",
|
|
361
|
+
additionalProperties: true,
|
|
362
|
+
description: "streamable-http: additional headers (string→string). WARNING: stored in plaintext."
|
|
363
|
+
},
|
|
364
|
+
toolCallTimeoutMs: {
|
|
365
|
+
type: "number",
|
|
366
|
+
description: "Per-tool-call timeout in ms (default 60000)."
|
|
367
|
+
},
|
|
368
|
+
failOnStartupError: {
|
|
369
|
+
type: "boolean",
|
|
370
|
+
description: "Fail plugin activation on initial connection error (default false)."
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
/** 从 defineTool 的 args(JsonValue 宽类型)构造 McpServerConfig。 */
|
|
374
|
+
function configFromArgs(args) {
|
|
375
|
+
const config = {
|
|
376
|
+
serverName: String(args.serverName ?? ""),
|
|
377
|
+
transport: args.transport === "streamable-http" ? "streamable-http" : "stdio"
|
|
378
|
+
};
|
|
379
|
+
if (typeof args.command === "string") config.command = args.command;
|
|
380
|
+
if (Array.isArray(args.args)) config.args = args.args.map(String);
|
|
381
|
+
if (args.env !== null && typeof args.env === "object") config.env = Object.fromEntries(Object.entries(args.env).map(([k, v]) => [k, String(v)]));
|
|
382
|
+
if (typeof args.cwd === "string") config.cwd = args.cwd;
|
|
383
|
+
if (typeof args.url === "string") config.url = args.url;
|
|
384
|
+
if (args.headers !== null && typeof args.headers === "object") config.headers = Object.fromEntries(Object.entries(args.headers).map(([k, v]) => [k, String(v)]));
|
|
385
|
+
if (typeof args.toolCallTimeoutMs === "number") config.toolCallTimeoutMs = args.toolCallTimeoutMs;
|
|
386
|
+
if (typeof args.failOnStartupError === "boolean") config.failOnStartupError = args.failOnStartupError;
|
|
387
|
+
return config;
|
|
388
|
+
}
|
|
389
|
+
function createMcpTools(deps) {
|
|
390
|
+
return [
|
|
391
|
+
defineTool({
|
|
392
|
+
name: "mcp_server_list",
|
|
393
|
+
description: "List registered MCP servers and their live tool counts. Each server is an @deepseek-ai/dsh-mcp-client instance mounted from the profile cordis.patch.yml insert row. status: connected (tools registered), connecting (mcp-client mounting/handshaking within the post-write grace window), disconnected (0 tools past the grace window — failed/exhausted).",
|
|
394
|
+
parameters: {},
|
|
395
|
+
output: {
|
|
396
|
+
schema: {
|
|
397
|
+
type: "object",
|
|
398
|
+
additionalProperties: false,
|
|
399
|
+
properties: { servers: {
|
|
400
|
+
type: "array",
|
|
401
|
+
required: true,
|
|
402
|
+
items: {
|
|
403
|
+
type: "object",
|
|
404
|
+
additionalProperties: false,
|
|
405
|
+
properties: {
|
|
406
|
+
id: {
|
|
407
|
+
type: "string",
|
|
408
|
+
required: true
|
|
409
|
+
},
|
|
410
|
+
serverName: {
|
|
411
|
+
type: "string",
|
|
412
|
+
required: true
|
|
413
|
+
},
|
|
414
|
+
transport: {
|
|
415
|
+
type: "string",
|
|
416
|
+
required: true
|
|
417
|
+
},
|
|
418
|
+
endpoint: {
|
|
419
|
+
type: "string",
|
|
420
|
+
required: true
|
|
421
|
+
},
|
|
422
|
+
toolCount: {
|
|
423
|
+
type: "number",
|
|
424
|
+
required: true
|
|
425
|
+
},
|
|
426
|
+
status: {
|
|
427
|
+
type: "string",
|
|
428
|
+
required: true,
|
|
429
|
+
enum: [
|
|
430
|
+
"connected",
|
|
431
|
+
"connecting",
|
|
432
|
+
"disconnected"
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} }
|
|
438
|
+
},
|
|
439
|
+
render: renderServers
|
|
440
|
+
},
|
|
441
|
+
async execute() {
|
|
442
|
+
const toolNames = deps.registeredToolNames();
|
|
443
|
+
return { servers: deps.listServers().map((r) => toServerView(r, toolNames, deps.connectingSince)) };
|
|
444
|
+
}
|
|
445
|
+
}),
|
|
446
|
+
defineTool({
|
|
447
|
+
name: "mcp_server_add",
|
|
448
|
+
description: "Register a new MCP server. Writes an mcp-client insert row into the profile cordis.patch.yml; the config HMR mounts the @deepseek-ai/dsh-mcp-client instance live (no restart). Connection lifecycle is delegated to the official mcp-client. env/headers are stored in PLAINTEXT — do not put long-lived secrets there without accepting the risk.",
|
|
449
|
+
parameters: SERVER_PARAMS,
|
|
450
|
+
output: {
|
|
451
|
+
schema: {
|
|
452
|
+
type: "object",
|
|
453
|
+
additionalProperties: false,
|
|
454
|
+
properties: {
|
|
455
|
+
ok: {
|
|
456
|
+
type: "boolean",
|
|
457
|
+
required: true
|
|
458
|
+
},
|
|
459
|
+
id: {
|
|
460
|
+
type: "string",
|
|
461
|
+
required: true
|
|
462
|
+
},
|
|
463
|
+
message: {
|
|
464
|
+
type: "string",
|
|
465
|
+
required: true
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
render: (_args, value) => [{
|
|
470
|
+
type: "text",
|
|
471
|
+
text: value.message
|
|
472
|
+
}]
|
|
473
|
+
},
|
|
474
|
+
async execute(args) {
|
|
475
|
+
const config = configFromArgs(args);
|
|
476
|
+
const id = deps.addServer(config);
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
id: String(id),
|
|
480
|
+
message: `mcp_server_add: registered "${config.serverName}" (id ${id}) — config HMR is mounting the mcp-client instance; tools will appear as mcp__${config.serverName}__* once the connection succeeds.`
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}),
|
|
484
|
+
defineTool({
|
|
485
|
+
name: "mcp_server_update",
|
|
486
|
+
description: "Update an MCP server config (replaces the whole config block, no deep merge). If serverName is unchanged, the public tool names stay the same; the mcp-client instance hot-swaps (disconnect + reconnect). serverName changes require uniqueness.",
|
|
487
|
+
parameters: {
|
|
488
|
+
id: {
|
|
489
|
+
type: "string",
|
|
490
|
+
required: true,
|
|
491
|
+
description: "The server insert-row id (e.g. mcp-github)."
|
|
492
|
+
},
|
|
493
|
+
...SERVER_PARAMS
|
|
494
|
+
},
|
|
495
|
+
output: {
|
|
496
|
+
schema: {
|
|
497
|
+
type: "object",
|
|
498
|
+
additionalProperties: false,
|
|
499
|
+
properties: {
|
|
500
|
+
ok: {
|
|
501
|
+
type: "boolean",
|
|
502
|
+
required: true
|
|
503
|
+
},
|
|
504
|
+
id: {
|
|
505
|
+
type: "string",
|
|
506
|
+
required: true
|
|
507
|
+
},
|
|
508
|
+
message: {
|
|
509
|
+
type: "string",
|
|
510
|
+
required: true
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
render: (_args, value) => [{
|
|
515
|
+
type: "text",
|
|
516
|
+
text: value.message
|
|
517
|
+
}]
|
|
518
|
+
},
|
|
519
|
+
async execute(args) {
|
|
520
|
+
const id = String(args.id);
|
|
521
|
+
const config = configFromArgs(args);
|
|
522
|
+
deps.updateServer(id, config);
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
id,
|
|
526
|
+
message: `mcp_server_update: replaced config for "${id}" (serverName=${config.serverName}) — mcp-client hot-swapping.`
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}),
|
|
530
|
+
defineTool({
|
|
531
|
+
name: "mcp_server_remove",
|
|
532
|
+
description: "Remove an MCP server. Deletes its insert row from the profile cordis.patch.yml; the mcp-client instance disposes and its tools (mcp__<serverName>__*) unregister via config HMR.",
|
|
533
|
+
parameters: { id: {
|
|
534
|
+
type: "string",
|
|
535
|
+
required: true,
|
|
536
|
+
description: "The server insert-row id to remove."
|
|
537
|
+
} },
|
|
538
|
+
output: {
|
|
539
|
+
schema: {
|
|
540
|
+
type: "object",
|
|
541
|
+
additionalProperties: false,
|
|
542
|
+
properties: {
|
|
543
|
+
ok: {
|
|
544
|
+
type: "boolean",
|
|
545
|
+
required: true
|
|
546
|
+
},
|
|
547
|
+
id: {
|
|
548
|
+
type: "string",
|
|
549
|
+
required: true
|
|
550
|
+
},
|
|
551
|
+
message: {
|
|
552
|
+
type: "string",
|
|
553
|
+
required: true
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
render: (_args, value) => [{
|
|
558
|
+
type: "text",
|
|
559
|
+
text: value.message
|
|
560
|
+
}]
|
|
561
|
+
},
|
|
562
|
+
async execute(args) {
|
|
563
|
+
const id = String(args.id);
|
|
564
|
+
if (!deps.deleteServer(id)) throw new Error(`mcp_server_remove: "${id}" is not a registered MCP server`);
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
id,
|
|
568
|
+
message: `mcp_server_remove: removed "${id}" — mcp-client disposing, tools unregistering.`
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
})
|
|
572
|
+
];
|
|
573
|
+
}
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/index.ts
|
|
576
|
+
/** Cordis 插件名。 */
|
|
577
|
+
const name = "dsh-mcp-manager";
|
|
578
|
+
/** 需要宿主 web server(web 组合)+ tools(注册 mcp_* 工具 + 读 schemas 浏览)。 */
|
|
579
|
+
const inject = ["webServer", "tools"];
|
|
580
|
+
/** 读请求体(POST/PUT)。 */
|
|
581
|
+
function readBody(req) {
|
|
582
|
+
return new Promise((resolve) => {
|
|
583
|
+
let body = "";
|
|
584
|
+
const r = req;
|
|
585
|
+
r.on?.("data", (c) => {
|
|
586
|
+
body += c.toString("utf8");
|
|
587
|
+
});
|
|
588
|
+
r.on?.("end", () => resolve(body));
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* 「连接中」宽限窗(毫秒)。addServer/updateServer 写入 insert 行后,mcp-client
|
|
593
|
+
* 经配置 HMR 挂载 → spawn 子进程/握手 → 同步工具,期间工具数=0。在该窗内
|
|
594
|
+
* 0 工具判为「连接中」而非「未连接」,避免面板长时间卡在误导性的「未连接」。
|
|
595
|
+
* 窗口过后仍 0 工具才判为「未连接/失败」(重连预算内或已耗尽)。
|
|
596
|
+
*
|
|
597
|
+
* 30s 覆盖典型首次连接 + 若干次指数退避(500ms→1s→2s→4s→8s→16s)。
|
|
598
|
+
* mcp-client 的连接状态封装在 startConnection 闭包内,外部无直接信号——
|
|
599
|
+
* 这是 P0 朴素推断(对齐开发计划 §4),非真实连接状态。
|
|
600
|
+
*/
|
|
601
|
+
const CONNECTING_GRACE_MS = 3e4;
|
|
602
|
+
/** 计算一个服务器的三态状态。 */
|
|
603
|
+
function computeStatus(toolCount, serverName, connectingSince) {
|
|
604
|
+
if (toolCount > 0) return "connected";
|
|
605
|
+
const since = connectingSince.get(serverName);
|
|
606
|
+
if (since !== void 0 && Date.now() - since < CONNECTING_GRACE_MS) return "connecting";
|
|
607
|
+
return "disconnected";
|
|
608
|
+
}
|
|
609
|
+
/** 注册控制台路由 + agent 工具。 */
|
|
610
|
+
function apply(ctx) {
|
|
611
|
+
ctx.effect(() => {
|
|
612
|
+
const loadOverlayPatches = resolveLoadOverlayPatches();
|
|
613
|
+
/**
|
|
614
|
+
* 各 serverName 最近一次「写配置」的时间戳(add/update)。用于推断「连接中」
|
|
615
|
+
* 中间状态——mcp-client 经 HMR 挂载到工具注册有延迟。进程内存:web 重启
|
|
616
|
+
* 后丢失,此时退化为「未连接」直到工具真正出现(可接受,重启少见)。
|
|
617
|
+
*/
|
|
618
|
+
const connectingSince = /* @__PURE__ */ new Map();
|
|
619
|
+
/** 记录一次配置写入,标记该 serverName 进入「连接中」宽限窗。 */
|
|
620
|
+
const markConnecting = (serverName) => {
|
|
621
|
+
connectingSince.set(serverName, Date.now());
|
|
622
|
+
};
|
|
623
|
+
const mcpTools = createMcpTools({
|
|
624
|
+
listServers,
|
|
625
|
+
addServer: (config, opts) => {
|
|
626
|
+
const id = addServer(config, {
|
|
627
|
+
...opts,
|
|
628
|
+
loadOverlayPatches
|
|
629
|
+
});
|
|
630
|
+
markConnecting(config.serverName);
|
|
631
|
+
return id;
|
|
632
|
+
},
|
|
633
|
+
updateServer: (id, config) => {
|
|
634
|
+
updateServer(id, config, { loadOverlayPatches });
|
|
635
|
+
markConnecting(config.serverName);
|
|
636
|
+
},
|
|
637
|
+
deleteServer: (id) => deleteServer(id, { loadOverlayPatches }),
|
|
638
|
+
registeredToolNames: () => (ctx.tools?.schemas() ?? []).map((s) => s.name),
|
|
639
|
+
connectingSince,
|
|
640
|
+
selfId: "@huanlin/dsh-plugin-mcp-manager"
|
|
641
|
+
});
|
|
642
|
+
const disposeTools = ctx.tools?.register !== void 0 ? mcpTools.map((tool) => ctx.tools.register(tool)) : [];
|
|
643
|
+
if (disposeTools.length > 0) console.log(`[dsh-mcp-manager] registered mcp tools: ${mcpTools.map((t) => t.name).join(", ")}`);
|
|
644
|
+
const webServer = ctx.webServer;
|
|
645
|
+
if (webServer === void 0) return () => {
|
|
646
|
+
for (const dispose of disposeTools) dispose();
|
|
647
|
+
};
|
|
648
|
+
const disposeRoutes = webServer.register({
|
|
649
|
+
kind: "prefix",
|
|
650
|
+
path: "/api/mcp-manager",
|
|
651
|
+
handler: async (req, res) => {
|
|
652
|
+
const json = (status, body) => {
|
|
653
|
+
res.statusCode = status;
|
|
654
|
+
res.setHeader("content-type", "application/json");
|
|
655
|
+
res.end(JSON.stringify(body));
|
|
656
|
+
};
|
|
657
|
+
const url = req?.url ?? "/";
|
|
658
|
+
const method = req?.method ?? "GET";
|
|
659
|
+
const path = url.split("?")[0] ?? "/";
|
|
660
|
+
const jsonRes = (status, body) => json(status, body);
|
|
661
|
+
try {
|
|
662
|
+
if (method === "GET" && (path === "/api/mcp-manager/servers" || path === "/api/mcp-manager/servers/")) {
|
|
663
|
+
const toolNames = (ctx.tools?.schemas() ?? []).map((s) => s.name);
|
|
664
|
+
jsonRes(200, {
|
|
665
|
+
ok: true,
|
|
666
|
+
servers: listServers().map((row) => {
|
|
667
|
+
const prefix = `mcp__${row.config.serverName}__`;
|
|
668
|
+
const toolCount = toolNames.filter((n) => n.startsWith(prefix)).length;
|
|
669
|
+
return {
|
|
670
|
+
id: row.id,
|
|
671
|
+
serverName: row.config.serverName,
|
|
672
|
+
transport: row.config.transport,
|
|
673
|
+
endpoint: row.config.transport === "stdio" ? `${row.config.command ?? ""} ${(row.config.args ?? []).join(" ")}`.trim() : row.config.url ?? "",
|
|
674
|
+
toolCount,
|
|
675
|
+
status: computeStatus(toolCount, row.config.serverName, connectingSince),
|
|
676
|
+
config: row.config
|
|
677
|
+
};
|
|
678
|
+
})
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (method === "GET" && (path === "/api/mcp-manager/tools" || path === "/api/mcp-manager/tools/")) {
|
|
683
|
+
const mcpToolsList = (ctx.tools?.schemas() ?? []).filter((s) => s.name.startsWith("mcp__")).map((s) => {
|
|
684
|
+
const parts = s.name.split("__");
|
|
685
|
+
const serverName = parts[1] ?? "";
|
|
686
|
+
const rawName = parts.slice(2).join("__");
|
|
687
|
+
return {
|
|
688
|
+
name: s.name,
|
|
689
|
+
serverName,
|
|
690
|
+
rawName,
|
|
691
|
+
description: s.description
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
const groups = {};
|
|
695
|
+
for (const t of mcpToolsList) {
|
|
696
|
+
const arr = groups[t.serverName] ?? [];
|
|
697
|
+
arr.push({
|
|
698
|
+
name: t.name,
|
|
699
|
+
rawName: t.rawName,
|
|
700
|
+
description: t.description
|
|
701
|
+
});
|
|
702
|
+
groups[t.serverName] = arr;
|
|
703
|
+
}
|
|
704
|
+
jsonRes(200, {
|
|
705
|
+
ok: true,
|
|
706
|
+
groups
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (method === "GET" && (path === "/api/mcp-manager/status" || path === "/api/mcp-manager/status/")) {
|
|
711
|
+
const toolNames = (ctx.tools?.schemas() ?? []).map((s) => s.name);
|
|
712
|
+
jsonRes(200, {
|
|
713
|
+
ok: true,
|
|
714
|
+
status: listServers().map((row) => {
|
|
715
|
+
const prefix = `mcp__${row.config.serverName}__`;
|
|
716
|
+
const toolCount = toolNames.filter((n) => n.startsWith(prefix)).length;
|
|
717
|
+
return {
|
|
718
|
+
id: row.id,
|
|
719
|
+
serverName: row.config.serverName,
|
|
720
|
+
toolCount,
|
|
721
|
+
status: computeStatus(toolCount, row.config.serverName, connectingSince)
|
|
722
|
+
};
|
|
723
|
+
})
|
|
724
|
+
});
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (method === "POST" && (path === "/api/mcp-manager/servers" || path === "/api/mcp-manager/servers/")) {
|
|
728
|
+
const body = await readBody(req);
|
|
729
|
+
const parsed = JSON.parse(body);
|
|
730
|
+
if (parsed.config === void 0) {
|
|
731
|
+
jsonRes(400, {
|
|
732
|
+
ok: false,
|
|
733
|
+
message: "config is required"
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const id = addServer(parsed.config, {
|
|
738
|
+
id: parsed.id,
|
|
739
|
+
loadOverlayPatches
|
|
740
|
+
});
|
|
741
|
+
markConnecting(parsed.config.serverName);
|
|
742
|
+
jsonRes(200, {
|
|
743
|
+
ok: true,
|
|
744
|
+
id,
|
|
745
|
+
live: true,
|
|
746
|
+
message: `server ${id} mounted via config HMR`
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const putMatch = /^\/api\/mcp-manager\/servers\/([^/]+)$/.exec(path);
|
|
751
|
+
if (method === "PUT" && putMatch !== null) {
|
|
752
|
+
const id = decodeURIComponent(putMatch[1]);
|
|
753
|
+
const body = await readBody(req);
|
|
754
|
+
const parsed = JSON.parse(body);
|
|
755
|
+
if (parsed.config === void 0) {
|
|
756
|
+
jsonRes(400, {
|
|
757
|
+
ok: false,
|
|
758
|
+
message: "config is required"
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
updateServer(id, parsed.config, { loadOverlayPatches });
|
|
763
|
+
markConnecting(parsed.config.serverName);
|
|
764
|
+
jsonRes(200, {
|
|
765
|
+
ok: true,
|
|
766
|
+
id,
|
|
767
|
+
live: true,
|
|
768
|
+
message: `server ${id} config replaced (HMR hot-swap)`
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (method === "DELETE" && putMatch !== null) {
|
|
773
|
+
const id = decodeURIComponent(putMatch[1]);
|
|
774
|
+
if (!deleteServer(id, { loadOverlayPatches })) {
|
|
775
|
+
jsonRes(404, {
|
|
776
|
+
ok: false,
|
|
777
|
+
message: `server "${id}" not found`
|
|
778
|
+
});
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
jsonRes(200, {
|
|
782
|
+
ok: true,
|
|
783
|
+
id,
|
|
784
|
+
message: `server ${id} removed (tools unregistering)`
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
jsonRes(404, {
|
|
789
|
+
ok: false,
|
|
790
|
+
message: "not found"
|
|
791
|
+
});
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (error instanceof RegistryError) jsonRes(400, {
|
|
794
|
+
ok: false,
|
|
795
|
+
field: error.field,
|
|
796
|
+
message: error.message
|
|
797
|
+
});
|
|
798
|
+
else jsonRes(500, {
|
|
799
|
+
ok: false,
|
|
800
|
+
message: error instanceof Error ? error.message : String(error)
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
return () => {
|
|
806
|
+
for (const dispose of disposeTools) dispose();
|
|
807
|
+
disposeRoutes();
|
|
808
|
+
};
|
|
809
|
+
}, "dsh-mcp-manager: config read/write route + mcp tools");
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
export { apply, inject, name };
|