dsh-project-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wldxiaobai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # dsh-project-mcp-manager
2
+
3
+ 独立项目级 MCP 自动加载插件:在项目根 `<projectRoot>/.dsh/mcp.yml` 写入 MCP
4
+ 服务器配置,在该项目开启 dsh 会话时自动装载(经官方
5
+ `@deepseek-ai/dsh-mcp-client`),文件改动热重载到运行中的 dsh 进程,并按
6
+ 会话 cwd 控制工具可见性。无 UI,核心功能精简型插件。
7
+
8
+ ## 工作原理
9
+
10
+ - **项目发现**:在线 agent 会话的 `session.header.cwd` + dsh 进程启动目录 →
11
+ 向上找最近的含 `.git` 的祖先目录作为项目根(无 `.git` 时退回目录本身)。
12
+ - **装载**:每个 `(项目, serverName)` 在宿主 ctx 上装载一个
13
+ `@deepseek-ai/dsh-mcp-client` 实例(`ctx.plugin`),注册进全局工具层。
14
+ 同一项目内多会话共享同一连接。
15
+ - **热重载**:chokidar 监听各项目根(depth 2,忽略 node_modules/.git/.hg/
16
+ .svn),`.dsh/mcp.yml` 的增删改经 150ms 防抖触发全量对账:新增行装载、
17
+ 删除行卸载、配置变化重装。
18
+ - **生效名**:原始 `serverName` 在整个目录(全局行 + 全部项目行)中唯一时
19
+ 保持原名;冲突时双方都改为 `p<sha256(项目根)前6位>_<原名>`(截断 32 字符,
20
+ 确定性、与装载顺序无关),避免 `dsh-mcp-client` 按进程根的 serverName
21
+ 预留冲突。全局行(profile `cordis.patch.yml` / bundle 层已装载的
22
+ mcp-client 行)参与占用判定但不改名。
23
+ - **会话可见性**:agent 创建时按其会话 cwd 解析项目,对该 agent 应用
24
+ `tools.restrict({ deny })`,deny 掉除本会话项目外的全部项目服务器;会话
25
+ 无 cwd 时回退 owner 项目(子代理),再回退 dsh 进程 cwd 所在项目。会话
26
+ 销毁时释放。
27
+
28
+ ## 配置格式
29
+
30
+ `<projectRoot>/.dsh/mcp.yml`,格式与 profile `cordis.patch.yml` 的受管块
31
+ 一致(begin/end 标记之间的 YAML insert 列表),每行一个 MCP 服务器:
32
+
33
+ ```yaml
34
+ # >>> dsh-project-mcp-manager:mcp:begin
35
+ - insert:
36
+ - id: panel-mcp-gitlab
37
+ name: '@deepseek-ai/dsh-mcp-client'
38
+ config:
39
+ serverName: gitlab
40
+ transport: stdio
41
+ command: npx
42
+ args: ['-y', '@modelcontextprotocol/server-gitlab']
43
+ cwd: . # 相对项目根解析
44
+ toolCallTimeoutMs: 60000
45
+ failOnStartupError: false
46
+ reconnect:
47
+ enabled: true
48
+ initialDelayMs: 500
49
+ maxDelayMs: 30000
50
+ maxAttempts: 10
51
+ # <<< dsh-project-mcp-manager:mcp:end
52
+ ```
53
+
54
+ `transport` 支持 `stdio`(command/args/env/cwd)与 `streamable-http`
55
+ (url/headers)。行加 `disabled: true` 即停用。标记之外的内容逐字节保留。
56
+
57
+ ## 安装(挂载到 profile)
58
+
59
+ 插件通过 **bundle patch** 挂载:把包加入 `dsh.profile.bundles` 后,dsh 启动时
60
+ 按顺序合成每个 bundle 的 patch(`dsh.bundle.patch` 指向的 `cordis.patch.yml`)
61
+ 作为插件行。**不要再往 profile 的 `cordis.patch.yml` 手动插入同名行**——两层
62
+ 同时存在会启动失败:`duplicate loader entry id: mcp-project`(EntryGroup
63
+ 拒绝重复 id)。二选一:留在 bundles,或移出 bundles 改手动插行,不能同时。
64
+
65
+ **本地开发安装**(未发布 npm 前的推荐方式,junction 实时同步源码):
66
+
67
+ ```powershell
68
+ cd C:\Users\haima\.dsh\profiles\web
69
+ pnpm add link:D:\path\to\dsh-mcp-project # 源码包目录
70
+ ```
71
+
72
+ 然后在 `package.json` 的 `dsh.profile.bundles` 数组末尾追加
73
+ `"dsh-project-mcp-manager"`,重启 dsh 生效(HMR 只热装首次 insert 的行,
74
+ 换实现需重启宿主进程)。
75
+
76
+ **发布后安装**:`pnpm add dsh-project-mcp-manager`(或 dsh 的插件子命令,
77
+ `dsh plugin --profile web ...`),同样确认包已加入 `dsh.profile.bundles`。
78
+
79
+ ## 与 dsh-skill-mcp-panel 的关系
80
+
81
+ - 已装的 v2.0.1 面板:无项目级装载能力,与本插件不冲突;其全局受管块行
82
+ 会被本插件读取(loader entries),项目同名行自动改名避开。
83
+ - v2.1 面板源码(含 ProjectMcpRegistry):与本插件功能重复,**同一进程内
84
+ 两者只能启用一个**(双装载同一 serverName 会触发 mcp-client 的进程根
85
+ 预留冲突)。v2.1 写出的项目文件行(`panel-mcp-` 前缀 id)本插件可直接
86
+ 读取,过渡平滑。
87
+
88
+ ## 构建与测试
89
+
90
+ ```powershell
91
+ npm install
92
+ npm run build # tsc → lib/
93
+ node test/test-model.mjs
94
+ node test/test-registry.mjs
95
+ ```
96
+
97
+ ## 安全边界
98
+
99
+ `.dsh/mcp.yml` 中的 `stdio` 行会在 dsh 宿主进程内 spawn 其 `command`——
100
+ 项目文件是**可执行代码载体**,只应在可信项目中添加。装载失败/配置无效行
101
+ 仅告警跳过,不影响其他服务器。
@@ -0,0 +1,7 @@
1
+ # dsh-project-mcp-manager bundle patch.
2
+ # When this package is installed with `dsh plugin --profile <name> add ...`,
3
+ # the profile auto-appends it to dsh.profile.bundles and applies this layer,
4
+ # so the plugin mounts without any manual cordis.patch.yml editing.
5
+ - insert:
6
+ - id: mcp-project
7
+ name: dsh-project-mcp-manager
package/lib/index.js ADDED
@@ -0,0 +1,44 @@
1
+ import { MCP_PLUGIN_NAME } from "./mcp-file.js";
2
+ import { ProjectMcpRegistry } from "./registry.js";
3
+ export const name = "dsh-project-mcp-manager";
4
+ /** agents 为硬依赖:宿主启动早期插件行先于 agents 服务装载时,等待其就绪后再 apply,
5
+ * 保证构造时的 liveAgents 补扫能看到已恢复/已存在的会话。 */
6
+ export const inject = ["tools", "agents"];
7
+ export function apply(ctx) {
8
+ const registry = new ProjectMcpRegistry(ctx, {
9
+ /** 全局已装载 mcp-client 行的 serverName:从 loader include entry 的
10
+ * config.patches(dsh-app-boot 传入的已生效 patch 配置,含 bundle 层
11
+ * 与 profile patch 层全部行)提取;loader.entries() 本身只暴露顶层
12
+ * include entry,子树行必须经 patches 展开。 */
13
+ globalNames: async () => {
14
+ try {
15
+ const loader = ctx.loader;
16
+ if (loader === undefined || typeof loader.entries !== "function")
17
+ return [];
18
+ const names = [];
19
+ for (const entry of loader.entries()) {
20
+ const patches = entry?.config?.patches ?? entry?.options?.config?.patches;
21
+ if (!Array.isArray(patches))
22
+ continue;
23
+ for (const patch of patches) {
24
+ const rows = Array.isArray(patch?.insert) ? patch.insert : [];
25
+ for (const row of rows) {
26
+ if (row?.name !== MCP_PLUGIN_NAME || row?.disabled === true)
27
+ continue;
28
+ const serverName = row?.config?.serverName;
29
+ if (typeof serverName === "string" && serverName !== "")
30
+ names.push(serverName);
31
+ }
32
+ }
33
+ }
34
+ return [...new Set(names)];
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ }
40
+ });
41
+ // registry 的装载/清理全部由其自身在构造时经 ctx.on / ctx.effect 注册,
42
+ // 此处只需保持引用存活(apply 作用域 + registry 内部 fiber 持有)。
43
+ void registry;
44
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * dsh-project-mcp-manager —— 项目 MCP 配置文件(<projectRoot>/.dsh/mcp.yml)编辑器。
3
+ *
4
+ * 项目文件使用与 profile cordis.patch.yml 相同的受管块格式:begin/end 标记
5
+ * 之间的 YAML insert 列表(与 @deepseek-ai/dsh-mcp-client 插件行同构)。
6
+ * 本插件只读写 begin/end 标记之间的行,标记之外的内容逐字节保留。
7
+ * 写入使用同目录临时文件 + rename,并通过锁文件避免并发写。
8
+ *
9
+ * 标记名与 dsh-skill-mcp-panel 不同(dsh-project-mcp-manager vs dsh-skill-mcp-panel),
10
+ * 但行 id 前缀兼容 panel 的 `panel-mcp-`,v2.1 面板写出的项目文件可直接读取。
11
+ */
12
+ import { open, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import { parseDocument, stringify } from "yaml";
15
+ export const MCP_BLOCK_BEGIN = "# >>> dsh-project-mcp-manager:mcp:begin";
16
+ export const MCP_BLOCK_END = "# <<< dsh-project-mcp-manager:mcp:end";
17
+ export const MCP_PLUGIN_NAME = "@deepseek-ai/dsh-mcp-client";
18
+ /** 兼容 dsh-skill-mcp-panel 的受管行 id 前缀(其项目文件行可直接装载)。 */
19
+ export const MANAGED_ROW_ID_PREFIX = "panel-mcp-";
20
+ const delay = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
21
+ /** 读取 patch 文件;缺失/读失败统一带路径报错。 */
22
+ export async function readPatchFile(path) {
23
+ try {
24
+ return await readFile(path, "utf8");
25
+ }
26
+ catch (error) {
27
+ throw new Error("无法读取 patch 文件(" + path + "):" + (error instanceof Error ? error.message : String(error)));
28
+ }
29
+ }
30
+ /** 校验整份 patch 文本:可解析且顶层是数组。不解出/写回任何值。 */
31
+ export async function validatePatchText(raw) {
32
+ const doc = parseDocument(raw, { logLevel: "silent" });
33
+ if (doc.errors.length > 0) {
34
+ throw new Error("cordis.patch.yml 解析失败:" + String(doc.errors[0]?.message ?? doc.errors[0]));
35
+ }
36
+ const parsed = doc.toJS();
37
+ if (!Array.isArray(parsed))
38
+ throw new Error("cordis.patch.yml 顶层必须是 YAML 数组");
39
+ }
40
+ /** 把 YAML 解析出的顶层条目拍平成 patch 行。 */
41
+ function flattenPatchRows(entries) {
42
+ const rows = [];
43
+ if (!Array.isArray(entries))
44
+ return rows;
45
+ const pushRow = (value) => {
46
+ if (value === null || typeof value !== "object" || Array.isArray(value))
47
+ return;
48
+ const row = value;
49
+ if (typeof row.id === "string" || typeof row.name === "string") {
50
+ const normalized = { ...row };
51
+ if (typeof row.id !== "string")
52
+ delete normalized.id;
53
+ if (typeof row.name !== "string")
54
+ delete normalized.name;
55
+ if (typeof row.disabled !== "boolean")
56
+ delete normalized.disabled;
57
+ if (row.config === null || typeof row.config !== "object" || Array.isArray(row.config))
58
+ delete normalized.config;
59
+ rows.push(normalized);
60
+ }
61
+ };
62
+ for (const entry of entries) {
63
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
64
+ continue;
65
+ const record = entry;
66
+ if (Array.isArray(record.insert)) {
67
+ for (const row of record.insert)
68
+ pushRow(row);
69
+ }
70
+ else {
71
+ pushRow(record);
72
+ }
73
+ }
74
+ return rows;
75
+ }
76
+ /** 提取 begin/end 标记之间的受管行;无标记返回空数组。 */
77
+ export function extractManagedRows(raw) {
78
+ const begin = raw.indexOf(MCP_BLOCK_BEGIN);
79
+ const end = raw.indexOf(MCP_BLOCK_END);
80
+ if (begin < 0 && end < 0)
81
+ return [];
82
+ if (begin < 0 || end < 0 || end < begin)
83
+ throw new Error("项目 MCP 文件中 dsh-project-mcp-manager 受管块标记不完整(begin/end 必须成对)");
84
+ const blockStart = raw.indexOf("\n", begin);
85
+ if (blockStart < 0)
86
+ throw new Error("项目 MCP 文件受管块格式损坏");
87
+ const blockText = raw.slice(blockStart + 1, end);
88
+ const doc = parseDocument(blockText, { logLevel: "silent" });
89
+ if (doc.errors.length > 0)
90
+ throw new Error("受管块解析失败:" + String(doc.errors[0]?.message ?? doc.errors[0]));
91
+ const parsed = doc.toJS();
92
+ if (!Array.isArray(parsed))
93
+ throw new Error("受管块内容必须是 YAML 数组");
94
+ return flattenPatchRows(parsed);
95
+ }
96
+ /** 生成受管块文本(无行时为空字符串)。 */
97
+ export function generateManagedBlock(rows) {
98
+ if (rows.length === 0)
99
+ return "";
100
+ const body = stringify([{ insert: rows }], { indent: 2, lineWidth: 0 });
101
+ return MCP_BLOCK_BEGIN + "\n" + body + MCP_BLOCK_END + "\n";
102
+ }
103
+ /**
104
+ * 替换受管块;无标记且要写入行时追加到文件末尾。标记之外的所有字节原样保留。
105
+ */
106
+ export function replaceManagedBlock(raw, rows) {
107
+ const begin = raw.indexOf(MCP_BLOCK_BEGIN);
108
+ const end = raw.indexOf(MCP_BLOCK_END);
109
+ const block = generateManagedBlock(rows);
110
+ if (begin >= 0 || end >= 0) {
111
+ if (begin < 0 || end < 0 || end < begin)
112
+ throw new Error("项目 MCP 文件中 dsh-project-mcp-manager 受管块标记不完整(begin/end 必须成对)");
113
+ const lineStart = raw.lastIndexOf("\n", begin - 1) + 1;
114
+ const afterEnd = raw.indexOf("\n", end);
115
+ const lineEnd = afterEnd < 0 ? raw.length : afterEnd + 1;
116
+ const next = raw.slice(0, lineStart) + block + raw.slice(lineEnd);
117
+ if (block !== "")
118
+ return next;
119
+ // 删除最后一批受管行后,原文件可能只剩注释;此时必须补回流式空数组,
120
+ // 否则文件不再是合法顶层数组。
121
+ const meaningful = next.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
122
+ if (meaningful.length === 0)
123
+ return next.replace(/\s*$/, "") + "\n[]\n";
124
+ return next;
125
+ }
126
+ if (block === "")
127
+ return raw;
128
+ // 空模板是流式空数组 `[]`:直接追加块序列会变成 `[] - insert`,
129
+ // 必须在追加前把 `[]` 替换为受管块序列。
130
+ const lines = raw.split(/\r?\n/);
131
+ const meaningful = lines.map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
132
+ if (meaningful.length === 1 && meaningful[0] === "[]") {
133
+ const index = raw.lastIndexOf("[]");
134
+ return raw.slice(0, index) + block + raw.slice(index + 2);
135
+ }
136
+ const prefix = raw.length === 0 ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
137
+ return raw + prefix + block;
138
+ }
139
+ /** 同目录临时文件 + rename 原子写;Windows 上 rename 覆盖失败时退化为 rm+rename。 */
140
+ export async function writeFileAtomic(path, content) {
141
+ const temp = join(dirname(path), ".dsh-project-mcp-manager-tmp-" + process.pid + "-" + Math.random().toString(36).slice(2, 8));
142
+ try {
143
+ await writeFile(temp, content, "utf8");
144
+ try {
145
+ await rename(temp, path);
146
+ }
147
+ catch (error) {
148
+ if (error === null || typeof error !== "object" || !["EPERM", "EEXIST", "EACCES"].includes(error.code ?? ""))
149
+ throw error;
150
+ await rm(path, { force: true });
151
+ await rename(temp, path);
152
+ }
153
+ }
154
+ finally {
155
+ await rm(temp, { force: true }).catch(() => { });
156
+ }
157
+ }
158
+ /**
159
+ * 以 `<path>.mcp-project.lock` 为锁执行 fn。锁文件记录 pid + 时间;超过 30 秒
160
+ * 视为陈旧锁自动清理。获取超时 5 秒。
161
+ */
162
+ export async function withPatchLock(path, fn) {
163
+ const lockPath = path + ".mcp-project.lock";
164
+ // 项目文件首次写入时父目录可能不存在:锁文件也需要父目录。
165
+ await mkdir(dirname(lockPath), { recursive: true });
166
+ const started = Date.now();
167
+ let handle;
168
+ while (handle === undefined) {
169
+ try {
170
+ handle = await open(lockPath, "wx");
171
+ }
172
+ catch (error) {
173
+ if (error === null || typeof error !== "object" || error.code !== "EEXIST")
174
+ throw error;
175
+ try {
176
+ const info = await stat(lockPath);
177
+ if (Date.now() - info.mtimeMs > 30000)
178
+ await rm(lockPath, { force: true }).catch(() => { });
179
+ }
180
+ catch {
181
+ // 锁在检查间隙被释放:继续重试。
182
+ }
183
+ if (Date.now() - started > 5000)
184
+ throw new Error("等待项目 MCP 文件写锁超时(可能有其他进程正在写入)");
185
+ await delay(50);
186
+ }
187
+ }
188
+ try {
189
+ await handle.writeFile(process.pid + "\n" + Date.now() + "\n", "utf8");
190
+ return await fn();
191
+ }
192
+ finally {
193
+ await handle.close().catch(() => { });
194
+ await rm(lockPath, { force: true }).catch(() => { });
195
+ }
196
+ }
197
+ /**
198
+ * 读取 patch 文件、替换受管块、校验、加锁原子写回。
199
+ * createIfMissing 时文件缺失(或父目录缺失)按空文件处理:用于项目级
200
+ * <projectRoot>/.dsh/mcp.yml 的首次写入。
201
+ * 返回写回后的完整文本。
202
+ */
203
+ export async function writeManagedRows(path, rows, options = {}) {
204
+ return withPatchLock(path, async () => {
205
+ let raw;
206
+ try {
207
+ raw = await readPatchFile(path);
208
+ }
209
+ catch (error) {
210
+ if (options.createIfMissing !== true)
211
+ throw error;
212
+ await mkdir(dirname(path), { recursive: true });
213
+ raw = "";
214
+ }
215
+ const next = replaceManagedBlock(raw, rows);
216
+ await validatePatchText(next);
217
+ await writeFileAtomic(path, next);
218
+ return next;
219
+ });
220
+ }
package/lib/model.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-project-mcp-manager —— MCP 服务器配置模型(纯函数)。
3
+ *
4
+ * 模型本身不含 scope:scope 是配置所在文件(profile cordis.patch.yml =
5
+ * 全局,<projectRoot>/.dsh/mcp.yml = 项目)的属性。env/headers 的 null 是
6
+ * 编辑语义:string = 覆盖该 key,null = 删除该 key,不出现 = 保留旧值。
7
+ */
8
+ import { z } from "zod";
9
+ import { createHash } from "node:crypto";
10
+ import { resolve } from "node:path";
11
+ import { MANAGED_ROW_ID_PREFIX, MCP_PLUGIN_NAME } from "./mcp-file.js";
12
+ export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
13
+ export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60000;
14
+ export const DEFAULT_RECONNECT = {
15
+ enabled: true,
16
+ initialDelayMs: 500,
17
+ maxDelayMs: 30000,
18
+ maxAttempts: 10
19
+ };
20
+ const serverNameSchema = z.string().regex(SERVER_NAME_RE, "serverName 只能包含 1-32 位字母、数字、下划线或连字符");
21
+ const secretMapSchema = z.record(z.string(), z.string().nullable()).optional();
22
+ const reconnectSchema = z.object({
23
+ enabled: z.boolean().default(DEFAULT_RECONNECT.enabled),
24
+ initialDelayMs: z.number().int().min(1).default(DEFAULT_RECONNECT.initialDelayMs),
25
+ maxDelayMs: z.number().int().min(1).default(DEFAULT_RECONNECT.maxDelayMs),
26
+ maxAttempts: z.number().int().min(1).default(DEFAULT_RECONNECT.maxAttempts)
27
+ }).default({ ...DEFAULT_RECONNECT });
28
+ export const stdioServerSchema = z.object({
29
+ serverName: serverNameSchema,
30
+ transport: z.literal("stdio"),
31
+ command: z.string().min(1),
32
+ args: z.array(z.string()).default([]),
33
+ env: secretMapSchema,
34
+ cwd: z.string().default(""),
35
+ toolCallTimeoutMs: z.number().int().min(1).default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
36
+ failOnStartupError: z.boolean().default(false),
37
+ reconnect: reconnectSchema
38
+ });
39
+ export const httpServerSchema = z.object({
40
+ serverName: serverNameSchema,
41
+ transport: z.literal("streamable-http"),
42
+ url: z.string().url(),
43
+ headers: secretMapSchema,
44
+ toolCallTimeoutMs: z.number().int().min(1).default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
45
+ failOnStartupError: z.boolean().default(false),
46
+ reconnect: reconnectSchema
47
+ });
48
+ export const mcpServerInputSchema = z.discriminatedUnion("transport", [stdioServerSchema, httpServerSchema]);
49
+ /** 项目根的规范化键(Windows 大小写不敏感),作 Map 键用。 */
50
+ export function projectKeyOf(projectRoot) {
51
+ const normalized = resolve(projectRoot);
52
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
53
+ }
54
+ /**
55
+ * 冲突时的命名空间生效名:`p<sha256(projectKeyOf(projectRoot))前6位hex>_<原始名>`,
56
+ * 截断到 32 字符(保持 SERVER_NAME_RE 合法)。确定性,与装载顺序、路径大小写
57
+ * 写法无关。
58
+ */
59
+ export function namespacedServerName(projectRoot, name) {
60
+ const hash = createHash("sha256").update(projectKeyOf(projectRoot)).digest("hex").slice(0, 6);
61
+ return ("p" + hash + "_" + name).slice(0, 32);
62
+ }
63
+ /**
64
+ * 计算全部 (projectRoot, 原始名) → 生效名的映射:
65
+ * 原始名在整个目录(全局行 + 所有项目行)中唯一时保持原名;否则该名的
66
+ * 每个项目拥有者都改用 namespacedServerName(全局行参与占用判定但不改名)。
67
+ * 键 = projectKeyOf(projectRoot) + "\0" + 原始名。
68
+ */
69
+ export function effectiveServerNames(projects, globalNames = []) {
70
+ const counts = new Map();
71
+ const add = (name) => counts.set(name, (counts.get(name) ?? 0) + 1);
72
+ for (const name of globalNames)
73
+ add(name);
74
+ for (const project of projects) {
75
+ for (const name of project.names)
76
+ add(name);
77
+ }
78
+ const result = new Map();
79
+ for (const project of projects) {
80
+ for (const name of project.names) {
81
+ result.set(projectKeyOf(project.projectRoot) + "\0" + name, (counts.get(name) ?? 0) > 1 ? namespacedServerName(project.projectRoot, name) : name);
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * 会话可见性:某会话项目应 deny 的生效名 = 全部已装载项目服务器 − 本会话
88
+ * 项目的。sessionProject 为 undefined(无 cwd / 未知项目)时 deny 全部。
89
+ */
90
+ export function denySetFor(sessionProject, mounted) {
91
+ const deny = new Set();
92
+ const own = sessionProject === undefined ? undefined : projectKeyOf(sessionProject);
93
+ for (const group of mounted) {
94
+ if (own !== undefined && projectKeyOf(group.projectRoot) === own)
95
+ continue;
96
+ for (const name of group.effectiveNames)
97
+ deny.add(name);
98
+ }
99
+ return [...deny];
100
+ }
101
+ /** 兼容 dsh-skill-mcp-panel 的行 id ↔ serverName。 */
102
+ export function rowIdForServerName(serverName) {
103
+ return MANAGED_ROW_ID_PREFIX + serverName;
104
+ }
105
+ export function serverNameFromRowId(id) {
106
+ if (typeof id !== "string" || !id.startsWith(MANAGED_ROW_ID_PREFIX))
107
+ return undefined;
108
+ const name = id.slice(MANAGED_ROW_ID_PREFIX.length);
109
+ return SERVER_NAME_RE.test(name) ? name : undefined;
110
+ }
111
+ /** null = 删除,string = 覆盖;缺省 key 保留旧值。 */
112
+ export function mergeSecretPatch(previous, patch) {
113
+ const merged = { ...(previous ?? {}) };
114
+ for (const [key, value] of Object.entries(patch ?? {})) {
115
+ if (value === null)
116
+ delete merged[key];
117
+ else
118
+ merged[key] = value;
119
+ }
120
+ return merged;
121
+ }
122
+ function normalizeReconnect(input) {
123
+ return {
124
+ enabled: input.reconnect.enabled,
125
+ initialDelayMs: input.reconnect.initialDelayMs,
126
+ maxDelayMs: input.reconnect.maxDelayMs,
127
+ maxAttempts: input.reconnect.maxAttempts
128
+ };
129
+ }
130
+ /** 面板输入 → 官方 @deepseek-ai/dsh-mcp-client 配置。 */
131
+ export function toOfficialConfig(input) {
132
+ const common = {
133
+ serverName: input.serverName,
134
+ toolCallTimeoutMs: input.toolCallTimeoutMs,
135
+ failOnStartupError: input.failOnStartupError,
136
+ reconnect: normalizeReconnect(input)
137
+ };
138
+ if (input.transport === "stdio") {
139
+ return {
140
+ ...common,
141
+ transport: "stdio",
142
+ command: input.command,
143
+ args: input.args,
144
+ env: mergeSecretPatch({}, input.env),
145
+ cwd: input.cwd
146
+ };
147
+ }
148
+ return {
149
+ ...common,
150
+ transport: "streamable-http",
151
+ url: input.url,
152
+ headers: mergeSecretPatch({}, input.headers)
153
+ };
154
+ }
155
+ /** 输入 → cordis.patch.yml / 项目 mcp.yml 行。 */
156
+ export function toPatchRow(input, enabled = true) {
157
+ return {
158
+ id: rowIdForServerName(input.serverName),
159
+ name: MCP_PLUGIN_NAME,
160
+ ...(enabled ? {} : { disabled: true }),
161
+ config: toOfficialConfig(input)
162
+ };
163
+ }
164
+ /** 读取 patch 行中的 config(宽松,坏行返回 undefined)。 */
165
+ export function configFromPatchRow(row) {
166
+ if (row === undefined || row.name !== MCP_PLUGIN_NAME)
167
+ return undefined;
168
+ if (row.config === null || typeof row.config !== "object" || Array.isArray(row.config))
169
+ return undefined;
170
+ return row.config;
171
+ }
172
+ function asString(value, fallback = "") {
173
+ return typeof value === "string" ? value : fallback;
174
+ }
175
+ function asStringArray(value) {
176
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
177
+ }
178
+ function asNumber(value, fallback) {
179
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
180
+ }
181
+ function asBoolean(value, fallback) {
182
+ return typeof value === "boolean" ? value : fallback;
183
+ }
184
+ function secretKeys(value) {
185
+ if (value === null || typeof value !== "object" || Array.isArray(value))
186
+ return [];
187
+ return Object.keys(value).filter((key) => typeof value[key] === "string");
188
+ }
189
+ /** patch 行 → 脱敏 view。密钥值不返回。scope/effectiveServerName 由调用方给出。 */
190
+ export function patchRowToView(row, scope, effectiveServerName) {
191
+ const config = configFromPatchRow(row);
192
+ if (config === undefined)
193
+ return undefined;
194
+ const serverName = asString(config.serverName);
195
+ if (!SERVER_NAME_RE.test(serverName))
196
+ return undefined;
197
+ const transport = config.transport === "streamable-http" ? "streamable-http" : config.transport === "stdio" ? "stdio" : "unknown";
198
+ const reconnectRaw = config.reconnect !== null && typeof config.reconnect === "object" && !Array.isArray(config.reconnect) ? config.reconnect : {};
199
+ return {
200
+ serverName,
201
+ transport,
202
+ enabled: row.disabled !== true,
203
+ entryId: row.id,
204
+ ...(scope === undefined ? {} : { scope }),
205
+ ...(effectiveServerName === undefined ? {} : { effectiveServerName }),
206
+ command: transport === "stdio" ? asString(config.command) : undefined,
207
+ args: transport === "stdio" ? asStringArray(config.args) : undefined,
208
+ envKeys: transport === "stdio" ? secretKeys(config.env) : [],
209
+ cwd: transport === "stdio" ? asString(config.cwd) : undefined,
210
+ url: transport === "streamable-http" ? asString(config.url) : undefined,
211
+ headerKeys: transport === "streamable-http" ? secretKeys(config.headers) : [],
212
+ toolCallTimeoutMs: asNumber(config.toolCallTimeoutMs, DEFAULT_TOOL_CALL_TIMEOUT_MS),
213
+ failOnStartupError: asBoolean(config.failOnStartupError, false),
214
+ reconnect: {
215
+ enabled: asBoolean(reconnectRaw.enabled, DEFAULT_RECONNECT.enabled),
216
+ initialDelayMs: asNumber(reconnectRaw.initialDelayMs, DEFAULT_RECONNECT.initialDelayMs),
217
+ maxDelayMs: asNumber(reconnectRaw.maxDelayMs, DEFAULT_RECONNECT.maxDelayMs),
218
+ maxAttempts: asNumber(reconnectRaw.maxAttempts, DEFAULT_RECONNECT.maxAttempts)
219
+ }
220
+ };
221
+ }
222
+ /** 从 patch 行读取完整输入(含 secret 值,仅供本机装载/测试使用,不跨 RPC)。 */
223
+ export function inputFromPatchRow(row) {
224
+ const config = configFromPatchRow(row) ?? {};
225
+ const serverName = asString(config.serverName, serverNameFromRowId(row.id) ?? "");
226
+ const common = {
227
+ serverName,
228
+ toolCallTimeoutMs: asNumber(config.toolCallTimeoutMs, DEFAULT_TOOL_CALL_TIMEOUT_MS),
229
+ failOnStartupError: asBoolean(config.failOnStartupError, false),
230
+ reconnect: {
231
+ enabled: asBoolean(config.reconnect?.enabled, DEFAULT_RECONNECT.enabled),
232
+ initialDelayMs: asNumber(config.reconnect?.initialDelayMs, DEFAULT_RECONNECT.initialDelayMs),
233
+ maxDelayMs: asNumber(config.reconnect?.maxDelayMs, DEFAULT_RECONNECT.maxDelayMs),
234
+ maxAttempts: asNumber(config.reconnect?.maxAttempts, DEFAULT_RECONNECT.maxAttempts)
235
+ }
236
+ };
237
+ if (config.transport === "streamable-http") {
238
+ return mcpServerInputSchema.parse({
239
+ ...common,
240
+ transport: "streamable-http",
241
+ url: asString(config.url),
242
+ headers: config.headers
243
+ });
244
+ }
245
+ return mcpServerInputSchema.parse({
246
+ ...common,
247
+ transport: "stdio",
248
+ command: asString(config.command),
249
+ args: asStringArray(config.args),
250
+ env: config.env,
251
+ cwd: asString(config.cwd)
252
+ });
253
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * dsh-project-mcp-manager —— 项目锚点解析。
3
+ *
4
+ * 与 dsh 官方 skills 发现(@deepseek-ai/dsh-skill-filesystem)的项目根规则
5
+ * 一致:向上找最近的含 .git 的祖先目录,找不到就退回 cwd 本身。
6
+ */
7
+ import { access } from "node:fs/promises";
8
+ import { dirname, join, resolve } from "node:path";
9
+ /** 判断文件系统路径是否存在。 */
10
+ export async function pathExists(path) {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** 项目锚点:向上找最近的含 .git 的祖先目录;找不到就退回 cwd 本身。 */
20
+ export async function findProjectRoot(cwd) {
21
+ let current = resolve(cwd);
22
+ while (true) {
23
+ if (await pathExists(join(current, ".git")))
24
+ return current;
25
+ const parent = dirname(current);
26
+ if (parent === current)
27
+ return resolve(cwd);
28
+ current = parent;
29
+ }
30
+ }