dsh-mcp-plus 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/lib/index.mjs ADDED
@@ -0,0 +1,328 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import z from "@deepseek-ai/schemastery";
7
+ //#region src/config.ts
8
+ /** 运行时校验器:required() 必填、default() 可选带默认值 */
9
+ const ServerSchema = z.object({
10
+ transport: z.const("stdio"),
11
+ serverName: z.string().required().pattern(/^[A-Za-z0-9_-]{1,32}$/),
12
+ command: z.string().required(),
13
+ args: z.array(String).default([]),
14
+ env: z.dict(String).default({}),
15
+ cwd: z.string().default("")
16
+ });
17
+ /** 服务器列表 schema:同时是 cordis 的 Config(如果将来 cordis.yml 传 config 也用得上) */
18
+ const Config = z.object({ servers: z.array(ServerSchema).default([]) });
19
+ /**
20
+ * 把用户输入规范化为 { servers: [...] }:
21
+ * 支持四种顶层形态(统一转成 schema 认识的形状)——
22
+ * ① { servers: [...] } —— 原生格式(servers.json 的存储格式)
23
+ * ② [ {...}, ... ] —— 裸数组
24
+ * ③ { "名字": {command,...}} —— 对象映射,key 即 serverName
25
+ * ④ { mcpServers: { 形态③ } } —— Claude Desktop 完整格式(mcpServers 包裹)
26
+ *
27
+ * 另外对每个条目做宽容处理:
28
+ * - 缺 transport 字段 → 自动补 'stdio'
29
+ * - 忽略未知字段(enabled 等)
30
+ */
31
+ function normalizeInput(input) {
32
+ const root = input;
33
+ if (typeof root === "object" && root !== null && !Array.isArray(root) && typeof root.mcpServers === "object" && root.mcpServers !== null) return normalizeInput(root.mcpServers);
34
+ if (typeof root === "object" && root !== null && !Array.isArray(root) && !("servers" in root)) return { servers: Object.entries(root).map(([serverName, raw]) => {
35
+ const cfg = typeof raw === "object" && raw !== null ? raw : {};
36
+ return {
37
+ transport: typeof cfg.transport === "string" ? cfg.transport : "stdio",
38
+ serverName,
39
+ command: cfg.command ?? "",
40
+ args: Array.isArray(cfg.args) ? cfg.args : [],
41
+ env: typeof cfg.env === "object" && cfg.env !== null ? cfg.env : {},
42
+ cwd: typeof cfg.cwd === "string" ? cfg.cwd : ""
43
+ };
44
+ }) };
45
+ if (Array.isArray(input)) return { servers: input.map((raw) => {
46
+ const cfg = typeof raw === "object" && raw !== null ? raw : {};
47
+ return {
48
+ transport: typeof cfg.transport === "string" ? cfg.transport : "stdio",
49
+ ...cfg
50
+ };
51
+ }) };
52
+ return input;
53
+ }
54
+ /**
55
+ * 校验一段未知数据(比如从 servers.json 读出来的 JSON)是不是合法的服务器列表。
56
+ * schemastery 是 standard-schema 实现,校验入口是 ['~standard'].validate(input),
57
+ * 返回 { value }(成功)或 { issues }(失败),不会静默填默认值。
58
+ * @returns 校验通过后的服务器列表
59
+ * @throws 校验失败时抛错(带 issues 详情)
60
+ */
61
+ function validateServers(input) {
62
+ const result = Config["~standard"].validate(normalizeInput(input));
63
+ if (result.issues) throw new Error("服务器配置校验失败: " + JSON.stringify(result.issues));
64
+ return result.value.servers;
65
+ }
66
+ //#endregion
67
+ //#region src/servers-store.ts
68
+ /** 配置文件绝对路径:~/.dsh/dsh-mcp-plus/servers.json */
69
+ const SERVERS_FILE = dshHomePath("dsh-mcp-plus", "servers.json");
70
+ /**
71
+ * 读配置文件,返回服务器列表。
72
+ * - 文件不存在 → 空列表(还没配置过任何服务器,正常情况)
73
+ * - 文件存在但非法 → 抛错(让上层知道配置坏了,别静默吞掉)
74
+ */
75
+ async function loadServers() {
76
+ let raw;
77
+ try {
78
+ raw = await readFile(SERVERS_FILE, "utf8");
79
+ } catch (err) {
80
+ if (err.code === "ENOENT") return [];
81
+ throw err;
82
+ }
83
+ return validateServers(JSON.parse(raw));
84
+ }
85
+ /**
86
+ * 写配置文件(全量覆盖)。
87
+ * 自动创建目录。写入前剥离运行时字段(status 等)——它们不该被持久化,
88
+ * 否则下次读取会被当作配置的一部分(servers.json 里已经出现过这个污染)。
89
+ */
90
+ async function saveServers(servers) {
91
+ await mkdir(dirname(SERVERS_FILE), { recursive: true });
92
+ const clean = servers.map(({ serverName, transport, command, args, env, cwd, enabled }) => ({
93
+ transport,
94
+ serverName,
95
+ command,
96
+ args,
97
+ env,
98
+ cwd,
99
+ ...enabled !== void 0 ? { enabled } : {}
100
+ }));
101
+ await writeFile(SERVERS_FILE, JSON.stringify({ servers: clean }, null, 2) + "\n", "utf8");
102
+ }
103
+ //#endregion
104
+ //#region src/http.ts
105
+ /** 我们的路由前缀(绝对路径,无尾斜杠) */
106
+ const ROUTE_PREFIX = "/dsh-mcp-plus";
107
+ /** 模块级连接状态表(serverName → connecting / connected / disconnected / disabled) */
108
+ const connectionStatus = /* @__PURE__ */ new Map();
109
+ /** host 半在每次 connect/disconnect 时更新这张表,UI 通过 GET 拿到 */
110
+ function setConnectionStatus(serverName, status) {
111
+ connectionStatus.set(serverName, status);
112
+ }
113
+ /** 清空状态表(disconnect 所有连接时) */
114
+ function clearConnectionStatus() {
115
+ connectionStatus.clear();
116
+ }
117
+ /** 读请求体(UTF-8 字符串) */
118
+ function readBody(req) {
119
+ return new Promise((resolve, reject) => {
120
+ const chunks = [];
121
+ req.on("data", (chunk) => chunks.push(chunk));
122
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
123
+ req.on("error", reject);
124
+ });
125
+ }
126
+ /** 发送 JSON 响应 */
127
+ function sendJson(res, status, body) {
128
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
129
+ res.end(JSON.stringify(body));
130
+ }
131
+ /**
132
+ * 注册配置读写端点。直接注册到 webServer(不挂 effect → 重连不卸载,端点一直可达)。
133
+ * @param ctx - host 半插件上下文(需要 webServer 服务)
134
+ * @param reload - 保存成功后异步触发的重连函数(不等待,fire-and-forget)
135
+ */
136
+ function registerHttpRoutes(ctx, reload) {
137
+ const webServer = ctx.webServer;
138
+ if (!webServer) {
139
+ console.log("[dsh-mcp-plus] webServer 服务不可用,HTTP 端点未注册(设置 UI 将无法保存)");
140
+ return;
141
+ }
142
+ webServer.register({
143
+ kind: "prefix",
144
+ path: ROUTE_PREFIX,
145
+ handler: async (req, res) => {
146
+ const url = (req.url ?? "/").split("?")[0];
147
+ const method = req.method ?? "GET";
148
+ const serversPath = "/dsh-mcp-plus/servers";
149
+ if (url === serversPath && method === "GET") {
150
+ try {
151
+ sendJson(res, 200, {
152
+ servers: (await loadServers()).map((s) => {
153
+ const liveStatus = connectionStatus.get(s.serverName);
154
+ const status = s.enabled === false ? "disabled" : liveStatus ?? "connecting";
155
+ return {
156
+ ...s,
157
+ status
158
+ };
159
+ }),
160
+ file: SERVERS_FILE
161
+ });
162
+ } catch (err) {
163
+ sendJson(res, 500, { error: "读取配置失败: " + (err instanceof Error ? err.message : String(err)) });
164
+ }
165
+ return;
166
+ }
167
+ if (url === serversPath && method === "PUT") {
168
+ try {
169
+ const body = await readBody(req);
170
+ const servers = validateServers(JSON.parse(body));
171
+ await saveServers(servers);
172
+ reload().catch((err) => {
173
+ console.log("[dsh-mcp-plus] 重连失败: " + (err instanceof Error ? err.message : String(err)));
174
+ });
175
+ sendJson(res, 200, {
176
+ ok: true,
177
+ count: servers.length
178
+ });
179
+ } catch (err) {
180
+ sendJson(res, 400, { error: "保存失败: " + (err instanceof Error ? err.message : String(err)) });
181
+ }
182
+ return;
183
+ }
184
+ sendJson(res, 404, { error: `未知端点: ${method} ${url}` });
185
+ }
186
+ });
187
+ }
188
+ //#endregion
189
+ //#region src/dsh-mcp-plus.ts
190
+ const name = "dsh-mcp-plus";
191
+ const inject = ["tools", "webServer"];
192
+ /**
193
+ * 生成模型可见的公开工具名:`mcp__<serverName>__<rawName>`(官方同款格式)
194
+ * 例:serverName=fs, rawName=read_file → mcp__fs__read_file
195
+ */
196
+ function publicToolName(serverName, rawName) {
197
+ return `mcp__${serverName}__${rawName}`;
198
+ }
199
+ /**
200
+ * 连接一台 MCP 服务器,发现工具并全部注册进 ctx.tools
201
+ * @returns 清理函数:断开连接
202
+ */
203
+ async function connectServer(ctx, cfg) {
204
+ const transport = new StdioClientTransport({
205
+ command: cfg.command,
206
+ args: cfg.args,
207
+ env: cfg.env,
208
+ cwd: cfg.cwd
209
+ });
210
+ const client = new Client({
211
+ name: "dsh-mcp-plus",
212
+ version: "0.0.1"
213
+ });
214
+ setConnectionStatus(cfg.serverName, "connecting");
215
+ try {
216
+ await client.connect(transport);
217
+ } catch (err) {
218
+ setConnectionStatus(cfg.serverName, "disconnected");
219
+ throw err;
220
+ }
221
+ setConnectionStatus(cfg.serverName, "connected");
222
+ console.log(`[dsh-mcp-plus] (${cfg.serverName}) 已连接,开始发现工具...`);
223
+ const { tools } = await client.listTools();
224
+ const toolDisposers = [];
225
+ for (const tool of tools) {
226
+ const definition = {
227
+ name: publicToolName(cfg.serverName, tool.name),
228
+ description: tool.description ?? "",
229
+ parameters: tool.inputSchema,
230
+ output: {
231
+ schema: {
232
+ type: "object",
233
+ properties: { content: {
234
+ type: "array",
235
+ items: {}
236
+ } },
237
+ required: ["content"],
238
+ additionalProperties: false
239
+ },
240
+ render: (_args, value) => {
241
+ return [{
242
+ type: "text",
243
+ text: (value.content ?? []).map((block) => block.type === "text" ? block.text ?? "" : `[${block.type}]`).join("\n")
244
+ }];
245
+ }
246
+ },
247
+ async execute(args) {
248
+ const argsObj = typeof args === "object" && args !== null ? args : {};
249
+ const result = await client.callTool({
250
+ name: tool.name,
251
+ arguments: argsObj
252
+ });
253
+ if (result.isError) throw new Error(`MCP 工具 ${tool.name} 执行失败`);
254
+ return { content: result.content };
255
+ }
256
+ };
257
+ try {
258
+ toolDisposers.push(ctx.tools.register(definition));
259
+ console.log(`[dsh-mcp-plus] (${cfg.serverName}) 已注册: ${definition.name}`);
260
+ } catch (err) {
261
+ console.log(`[dsh-mcp-plus] (${cfg.serverName}) 注册失败 ${definition.name}: ${err instanceof Error ? err.message : String(err)}`);
262
+ }
263
+ }
264
+ console.log(`[dsh-mcp-plus] (${cfg.serverName}) 完成:共 ${tools.length} 个工具`);
265
+ return async () => {
266
+ setConnectionStatus(cfg.serverName, "disconnected");
267
+ for (const dispose of toolDisposers) try {
268
+ dispose();
269
+ } catch (err) {
270
+ console.log(`[dsh-mcp-plus] (${cfg.serverName}) 注销工具失败(继续): ${err instanceof Error ? err.message : String(err)}`);
271
+ }
272
+ await client.close();
273
+ };
274
+ }
275
+ /**
276
+ * 读配置文件 → 连接全部服务器 → 返回总清理函数。
277
+ * 初次加载和 UI 保存后的重连共用这一个函数。
278
+ * enabled === false 的服务器跳过(已配置但不连接)。
279
+ * 单台失败不影响其它服务器(容错),失败仅记日志,状态表标 disconnected。
280
+ */
281
+ async function connectAllServers(ctx) {
282
+ clearConnectionStatus();
283
+ const allServers = await loadServers();
284
+ const seen = /* @__PURE__ */ new Set();
285
+ for (const server of allServers) {
286
+ if (seen.has(server.serverName)) throw new Error(`[dsh-mcp-plus] serverName "${server.serverName}" 重复——每台服务器必须有唯一名字`);
287
+ seen.add(server.serverName);
288
+ }
289
+ const active = allServers.filter((s) => s.enabled !== false);
290
+ const skipped = allServers.length - active.length;
291
+ if (skipped > 0) console.log(`[dsh-mcp-plus] 跳过 ${skipped} 台禁用服务器`);
292
+ for (const s of allServers) if (s.enabled === false) setConnectionStatus(s.serverName, "disabled");
293
+ const disposers = [];
294
+ for (const server of active) try {
295
+ disposers.push(await connectServer(ctx, server));
296
+ } catch (err) {
297
+ console.log(`[dsh-mcp-plus] (${server.serverName}) 连接失败: ${err instanceof Error ? err.message : String(err)}`);
298
+ }
299
+ console.log(`[dsh-mcp-plus] 加载了 ${active.length} 台服务器(${disposers.length} 成功)`);
300
+ return async () => {
301
+ await Promise.all(disposers.map((dispose) => dispose()));
302
+ };
303
+ }
304
+ function apply(ctx) {
305
+ let currentDispose;
306
+ /** 读配置 → 断开旧连接 → 连接新配置(初次加载与重连共用) */
307
+ const connectAll = async () => {
308
+ if (currentDispose) {
309
+ try {
310
+ await currentDispose();
311
+ } catch (err) {
312
+ console.log("[dsh-mcp-plus] 断开旧连接失败(继续): " + (err instanceof Error ? err.message : String(err)));
313
+ }
314
+ currentDispose = void 0;
315
+ }
316
+ currentDispose = await connectAllServers(ctx);
317
+ };
318
+ registerHttpRoutes(ctx, connectAll);
319
+ ctx.effect(() => {
320
+ connectAll();
321
+ return async () => {
322
+ if (currentDispose) await currentDispose();
323
+ currentDispose = void 0;
324
+ };
325
+ }, "dsh-mcp-plus.servers");
326
+ }
327
+ //#endregion
328
+ export { apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "dsh-mcp-plus",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "lib/index.mjs",
6
+ "description": "DSH 增强型 MCP 客户端插件:在设置界面管理多台 MCP 服务器,自动发现并注册工具给 Agent",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sojo-negai/dsh-mcp-plus.git"
11
+ },
12
+ "keywords": [
13
+ "dsh",
14
+ "dsh-plugin",
15
+ "deepseek-harness",
16
+ "mcp",
17
+ "mcp-client",
18
+ "model-context-protocol"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "default": "./lib/index.mjs"
23
+ },
24
+ "./client": {
25
+ "default": "./lib/client.iife.js"
26
+ },
27
+ "./src/*": "./src/*",
28
+ "./cordis.patch.yml": "./cordis.patch.yml",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "lib",
33
+ "src",
34
+ "scripts",
35
+ "cordis.patch.yml",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsdown && tsdown --config ./tsdown.client.config.ts",
41
+ "prepack": "npm run build"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web"
49
+ }
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.12.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@deepseek-ai/dsh-home-paths": ">=0.1.0-0 <0.1.1-0 || >=0.1.1-0 <0.1.2-0 || >=0.1.2-0 <0.2.0-0",
56
+ "@deepseek-ai/schemastery": "^3.18.1"
57
+ },
58
+ "devDependencies": {
59
+ "tsdown": "^0.23.0",
60
+ "typescript": "^5.9.2"
61
+ }
62
+ }
@@ -0,0 +1,105 @@
1
+ // scripts/install.mjs —— 把 dsh-mcp-plus 装进指定 DSH profile
2
+ //
3
+ // 用法:
4
+ // node scripts/install.mjs --profile web # 装到单个 profile
5
+ // node scripts/install.mjs --all # 装到所有 profile
6
+ // node scripts/install.mjs --profile web --remove # 从 web 卸载
7
+ //
8
+ // 装入的动作:
9
+ // 1. 在 <profile>/package.json 加 file: 依赖,指向当前插件根目录
10
+ // 2. 把 'dsh-mcp-plus' 加到 dsh.profile.bundles 数组(bundle 才会被加载)
11
+ //
12
+ // 卸载时反向操作:从 dependencies / bundles 移除。
13
+
14
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ // 插件根 = scripts/ 的上一级
20
+ const PLUGIN_ROOT = dirname(__dirname)
21
+ const PLUGIN_PATH = PLUGIN_ROOT // file: 路径要求绝对路径
22
+
23
+ const PROFILE_DIR = process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME, '.dsh')
24
+ const ALL_PROFILES = ['desktop', 'web', 'open-design'] // 按需扩展
25
+
26
+ function parseArgs(argv) {
27
+ const args = { profile: null, all: false, remove: false, help: false }
28
+ for (let i = 2; i < argv.length; i++) {
29
+ const a = argv[i]
30
+ if (a === '--profile') args.profile = argv[++i]
31
+ else if (a === '--all') args.all = true
32
+ else if (a === '--remove') args.remove = true
33
+ else if (a === '--help' || a === '-h') args.help = true
34
+ }
35
+ return args
36
+ }
37
+
38
+ function usage() {
39
+ console.log(`用法:
40
+ node scripts/install.mjs --profile <name> 安装到指定 profile
41
+ node scripts/install.mjs --all 安装到所有 profile
42
+ node scripts/install.mjs --profile <name> --remove 从指定 profile 卸载
43
+
44
+ 可用 profile: ${ALL_PROFILES.join(', ')}`)
45
+ }
46
+
47
+ function listProfiles() {
48
+ return ALL_PROFILES.filter((n) => existsSync(join(PROFILE_DIR, 'profiles', n, 'package.json')))
49
+ }
50
+
51
+ function applyProfile(profileName, remove) {
52
+ const pkgPath = join(PROFILE_DIR, 'profiles', profileName, 'package.json')
53
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
54
+ const deps = pkg.dependencies ?? {}
55
+ const dshProfile = pkg.dsh?.profile ?? {}
56
+ const bundles = dshProfile.bundles ?? []
57
+
58
+ if (remove) {
59
+ let changed = false
60
+ if (deps['dsh-mcp-plus']) {
61
+ delete deps['dsh-mcp-plus']
62
+ changed = true
63
+ }
64
+ const idx = bundles.indexOf('dsh-mcp-plus')
65
+ if (idx >= 0) {
66
+ bundles.splice(idx, 1)
67
+ changed = true
68
+ }
69
+ if (changed) {
70
+ pkg.dependencies = deps
71
+ pkg.dsh = pkg.dsh ?? {}
72
+ pkg.dsh.profile = dshProfile
73
+ pkg.dsh.profile.bundles = bundles
74
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
75
+ console.log(` ✓ 已从 ${profileName} 卸载`)
76
+ } else {
77
+ console.log(` - ${profileName} 本来就没装`)
78
+ }
79
+ } else {
80
+ deps['dsh-mcp-plus'] = `file:${PLUGIN_PATH}`
81
+ if (!bundles.includes('dsh-mcp-plus')) bundles.push('dsh-mcp-plus')
82
+ pkg.dependencies = deps
83
+ pkg.dsh = pkg.dsh ?? {}
84
+ pkg.dsh.profile = dshProfile
85
+ pkg.dsh.profile.bundles = bundles
86
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
87
+ console.log(` ✓ 已装到 ${profileName}`)
88
+ }
89
+ }
90
+
91
+ const args = parseArgs(process.argv)
92
+ if (args.help || (!args.profile && !args.all)) {
93
+ usage()
94
+ process.exit(args.help ? 0 : 1)
95
+ }
96
+
97
+ const targets = args.all ? listProfiles() : [args.profile]
98
+ for (const name of targets) {
99
+ if (!existsSync(join(PROFILE_DIR, 'profiles', name))) {
100
+ console.warn(`! ${name} 不存在,跳过`)
101
+ continue
102
+ }
103
+ applyProfile(name, args.remove)
104
+ }
105
+ console.log('完成。')