dsh-project-mcp-manager 0.1.1 → 0.3.1

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 CHANGED
@@ -33,7 +33,7 @@ dsh plugin --profile web add dsh-project-mcp-manager@latest
33
33
 
34
34
  # Install a specific version (check available versions with
35
35
  # npm view dsh-project-mcp-manager versions)
36
- dsh plugin --profile web add dsh-project-mcp-manager@0.1.0
36
+ dsh plugin --profile web add dsh-project-mcp-manager@0.2.0
37
37
  ```
38
38
 
39
39
  **Option 2: install directly with pnpm** (equivalent to option 1):
@@ -53,7 +53,7 @@ pnpm add link:<path-to-your-dsh-mcp-project-source> # e.g. D:\dev\dsh-mcp-proj
53
53
  ```
54
54
 
55
55
  **Upgrading / pinning versions**: re-run the `add` command from option 1 with
56
- the desired version suffix — `@latest` upgrades to the newest release, `@0.1.0`
56
+ the desired version suffix — `@latest` upgrades to the newest release, `@0.2.0`
57
57
  pins to a specific version.
58
58
 
59
59
  ## Build & test
@@ -61,8 +61,7 @@ pins to a specific version.
61
61
  ```powershell
62
62
  npm install
63
63
  npm run build # tsc → lib/
64
- node test/test-model.mjs
65
- node test/test-registry.mjs
64
+ npm test # node test/test-model.mjs / test-mcp-file / test-cc-file / test-registry / test-cli
66
65
  ```
67
66
 
68
67
  ## Configuration format
@@ -96,6 +95,152 @@ MCP server per line:
96
95
  (url/headers). Add `disabled: true` to a line to deactivate it. Content outside
97
96
  the markers is preserved byte-for-byte.
98
97
 
98
+ **Divergences from the native cordis dialect**: the `!!js` tag (a js-yaml
99
+ expression evaluated by the profile loader, e.g. the official README's
100
+ `env: { TOKEN: !!js process.env.GITHUB_TOKEN }`) is **not supported** in
101
+ project files — an unresolved tag inside the managed block makes the whole
102
+ file fail with an explicit error (logged and written to `.dsh/.mcp-diag.json`)
103
+ instead of silently mounting the expression text as a literal string. Values
104
+ in `env`/`headers` are otherwise literal, except for `${VAR}` references
105
+ which are interpolated at mount time (see `${VAR}` expansion below);
106
+ `disabled` must be `true`/`false`. The project file is otherwise a superset
107
+ grammar: `env`/`headers` accept `KEY: null` to delete a key (stripped at
108
+ mount), which the official mcp-client schema rejects — such lines would fail
109
+ if moved back to `cordis.patch.yml`.
110
+
111
+ ## Claude Code compatibility (read-only)
112
+
113
+ To fit the habits of Claude Code users, two CC locations are **loaded** but
114
+ never written by this plugin — with different defaults, because they are
115
+ different risk classes:
116
+
117
+ - `<projectRoot>/.mcp.json` — the CC project file (`{ "mcpServers": { … } }`).
118
+ An in-repo, version-controlled project declaration: **on by default**,
119
+ read-only. Turn the whole layer off with `DSH_MCP_IGNORE_MCP_JSON=1`.
120
+ - `~/.claude.json` — only the **top-level `mcpServers`** subtree is read, and
121
+ only **on explicit request**: set `DSH_MCP_READ_CLAUDE_USER=1`. This is CC's
122
+ machine-wide user state, not something any one project asked for: mounting
123
+ it unconditionally fans every such server into *every* known dsh project as
124
+ its own process — the exact incident that made it opt-in. Nothing else in
125
+ the file is ever touched, logged or displayed (strict allowlist: oauth
126
+ credentials, per-project history and UI state stay invisible to this
127
+ plugin).
128
+
129
+ Notes and limits:
130
+
131
+ - **No `local` scope.** CC's `claude mcp add` defaults to a per-project
132
+ section inside `~/.claude.json` (`projects.<cwd>.mcpServers`); this plugin
133
+ does not read it. Use `dsh-mcp add --scope user` or the project files.
134
+ - `type: "sse"` entries are rejected with a per-entry diagnostic — the mount
135
+ backend (`dsh-mcp-client`) only speaks `stdio` and `streamable-http`.
136
+ CC's `type: "http"` and an explicit `type: "streamable-http"` both map to
137
+ `streamable-http`; with `type` omitted, an entry that has only `url` (no
138
+ `command`) is treated as http, everything else as stdio.
139
+ - stdio `cwd`: for project-layer rows an empty `cwd` resolves to the project
140
+ root; for user-layer rows (both `~/.dsh/mcp.yml` and `~/.claude.json`) it
141
+ resolves to the dsh host's working directory. Note what "user layer" does
142
+ *not* mean: user rows still mount **one process per known project** (a
143
+ session in two projects gets two fibers, renamed `p<hash>_…` per the
144
+ effective-name rules) — only their `cwd` is shared, not the connection.
145
+ - Unknown CC keys are ignored per entry. `enabled: false` — and, as an alias,
146
+ `disabled: true` — skips the row silently (no diagnostic, and no name
147
+ occupancy — see below).
148
+ - A `disabled: true` row in a **native yml** still holds its shadow keys in
149
+ the shadow chain (see dedup below): same-name rows in lower layers are
150
+ shadowed too and stay unmounted — disabled means "this name must not run",
151
+ not "let the CC copy through". CC-side `enabled: false` has no placeholder
152
+ effect. Consequence: switching off a `.mcp.json` entry without deleting it
153
+ lets a **same-named user-layer row surface** in that project; to suppress a
154
+ server across all layers, keep a `disabled: true` placeholder row (matching
155
+ name or normalized name) in `.dsh/mcp.yml`.
156
+ - Broken files/entries never take down the valid ones; each source fails
157
+ independently, so an unreadable `.mcp.json` cannot unmount the project's
158
+ yml rows (and vice versa). Entry errors land in `.dsh/.mcp-diag.json` and
159
+ the host log (file content is never echoed). A project whose only MCP
160
+ config is `.mcp.json` still gets a `.dsh/` directory as soon as there is
161
+ anything worth reporting there.
162
+ - `~/.claude.json` is rewritten by CC on every session; the watcher re-reads
163
+ it but only triggers reconciliation when the `mcpServers` subtree actually
164
+ changed (canonical-JSON hash gate).
165
+ - Boundary switches (dsh host environment): `DSH_MCP_READ_CLAUDE_USER=1`
166
+ opts the `~/.claude.json` user layer in; `DSH_MCP_IGNORE_MCP_JSON=1` turns
167
+ the project `.mcp.json` layer off; the legacy `DSH_MCP_IGNORE_CLAUDE_JSON=1`
168
+ force-disables the user layer and **wins over the opt-in** (a one-shot
169
+ warning names the winner; the legacy switch will be removed in a later
170
+ release). While the user layer is on, the host logs its fan-out size once
171
+ ("N servers will join M known projects") so extra spawns are explainable.
172
+
173
+ **Shadow priority** — layers merge first-come-first-served (1 → 4 below), and
174
+ a row is shadowed when it collides with an earlier row on **any** of three
175
+ keys: the exact `serverName`; the *normalized name* (lowercased with
176
+ non-alphanumerics stripped — `unityMCP` and `unity-mcp` are one service
177
+ written two ways); or the *service identity* (`stdio`: command + args,
178
+ path-case-insensitive on Windows; `streamable-http`: the url). Rows without a
179
+ command/url register no identity key — `node a.js` and `node b.js` stay
180
+ different services — while `disabled` placeholder rows hold all three keys
181
+ without mounting anything. Identity is compared on the **raw strings as
182
+ written in the file, before `${VAR}` expansion**, and `env`, `headers`, `cwd`
183
+ are *not* part of the key: two genuinely different servers sharing one command
184
+ line (but e.g. different env) still collapse to the winner, while the same
185
+ server written once with a `${VAR}` and once as a literal does not match. If a
186
+ drop was unintended, rename the loser (past normalization) or adjust its
187
+ command line. Losers are reported in `.dsh/.mcp-diag.json`
188
+ (`shadowedByYml` / `shadowedByProject` / `shadowedIdentity`) and every
189
+ identity/normalized-name drop warns in the host log:
190
+
191
+ 1. `<projectRoot>/.dsh/mcp.yml` (native, panel/CLI-managed)
192
+ 2. `<projectRoot>/.mcp.json` (CC project)
193
+ 3. `~/.dsh/mcp.yml` (native user layer — see CLI)
194
+ 4. `~/.claude.json` top-level `mcpServers` (CC user, opt-in)
195
+
196
+ User-layer rows apply to every known project, so a name defined both in one
197
+ project and in a user layer (or in two projects) participates in the regular
198
+ effective-name conflict renaming (see How it works).
199
+
200
+ ## `${VAR}` expansion
201
+
202
+ `${VAR}` references (matching `\$\{[A-Za-z_][A-Za-z0-9_]*\}` anywhere in the
203
+ string) in `command`, `args[*]`, `env[*]`, `cwd`, `url` and `headers[*]` — from
204
+ **any** of the four sources above — are **interpolated** from the dsh host
205
+ process environment at mount time (same semantics as Claude Code, so
206
+ `"Authorization": "Bearer ${TOKEN}"` works). An unset or empty variable makes
207
+ the row skip with an `env-missing` diagnostic naming the variable (never its
208
+ value); a literal `${NAME}` that must survive unexpanded is not expressible.
209
+ A `url` containing a reference is accepted by the pre-mount schema in any
210
+ position — including the host part, e.g. `https://${HOST}/mcp` — because
211
+ validity is judged only after expansion: expanded inputs are re-validated
212
+ against the mount schema before spawn, and a malformed result (e.g. a non-URL
213
+ `${GATEWAY}/mcp`) skips the row with an `env-invalid` diagnostic instead of
214
+ reaching the mount backend. In the snapshot/row views, `fiberPhase` stays on
215
+ the mount-lifecycle vocabulary (`pending` for a row that never mounted) and
216
+ the reason rides on a separate `skipReason` field (`env-missing` /
217
+ `env-invalid` / `config-invalid` / `plugin-throw`). Values are never
218
+ persisted anywhere by the plugin; the CLI writes `${VAR}` through literally,
219
+ so secrets can live in the environment while configs live in git.
220
+
221
+ ## CLI: `dsh-mcp`
222
+
223
+ CC-style command-line management for the **native** files (writes only
224
+ `.dsh/mcp.yml` — never `.mcp.json` / `~/.claude.json`; running hosts
225
+ converge via the file watchers, no dsh connection needed):
226
+
227
+ ```powershell
228
+ dsh-mcp add gitlab npx -y @modelcontextprotocol/server-gitlab -e GITLAB_TOKEN=${GITLAB_TOKEN}
229
+ dsh-mcp add --transport http sentry https://mcp.sentry.dev/mcp -H "Authorization: Bearer ${SENTRY_TOKEN}"
230
+ dsh-mcp add --scope user shared node ./tools/shared.js # writes ~/.dsh/mcp.yml
231
+ dsh-mcp list # all four layers, with shadow annotations
232
+ dsh-mcp get gitlab # winning entry, secret values shown as key names only
233
+ dsh-mcp remove gitlab # native yml only; read-only layers get guidance
234
+ ```
235
+
236
+ Scopes: `--scope project` (default; writes `<projectRoot>/.dsh/mcp.yml` under
237
+ the nearest `.git` ancestor) and `--scope user` (writes `~/.dsh/mcp.yml`,
238
+ mounted into every project). `add` defaults `cwd` to `"."` (project root) for
239
+ project scope and `""` (host directory) for user scope; `-c` overrides.
240
+ There is no `local` scope — `--scope local`
241
+ fails with an explanation. `--transport` accepts `stdio` (default) and `http`;
242
+ `sse` is refused (unsupported by the backend).
243
+
99
244
  ## How it works
100
245
 
101
246
  - **Project discovery**: the `session.header.cwd` of an active agent session,
@@ -107,18 +252,29 @@ the markers is preserved byte-for-byte.
107
252
  registers it into the global tool layer. Multiple sessions inside the same
108
253
  project share a single connection.
109
254
  - **Hot reload**: chokidar watches each project root (depth 2, ignoring
110
- node_modules/.git/.hg/.svn); changes to `.dsh/mcp.yml` trigger a full
111
- reconciliation after a 150 ms debounce: added lines are mounted, removed
112
- lines are unmounted, and config changes are remounted.
255
+ node_modules/.git/.hg/.svn), but only edits to the **exact** config files of
256
+ known project roots — `<projectRoot>/.dsh/mcp.yml` and
257
+ `<projectRoot>/.mcp.json` — trigger a full reconciliation after a 150 ms
258
+ debounce: added lines are mounted, removed lines are unmounted, and config
259
+ changes are remounted. A second watcher covers the user layer as two
260
+ **exact file paths** — `~/.dsh/mcp.yml` and `~/.claude.json` (chokidar v5
261
+ notices a watched file being created as long as its parent directory
262
+ exists) — never the home directory at large. `~/.claude.json` events are
263
+ arbitrated by the canonical-JSON content hash alone (no size/mtime fast
264
+ path: same-instant, same-length rewrites with different content must not be
265
+ swallowed).
113
266
  - **Effective names**: when the original `serverName` is unique across the
114
- whole catalog (global lines + all project lines) it keeps its name; on a
115
- conflict both sides are renamed to
267
+ whole catalog (global lines + all project lines, where each project's
268
+ merged rows include the user-layer rows that survived shadowing) it keeps
269
+ its name; on a conflict both sides are renamed to
116
270
  `p<first 6 chars of sha256(projectRoot)>_<original name>` (truncated to 32
117
271
  characters, deterministic and independent of mount order) to avoid the
118
272
  serverName reservation conflicts that `dsh-mcp-client` makes per process
119
273
  root. Global lines (profile `cordis.patch.yml` / mcp-client lines already
120
274
  mounted at the bundle level) participate in occupancy determination but are
121
- never renamed.
275
+ never renamed. Model-visible tool names are built from the **effective**
276
+ server name and the MCP tool's own name (`mcp__<effectiveServerName>__<toolName>`),
277
+ which may differ from the `serverName` written in the file.
122
278
  - **Session visibility**: when an agent is created, its session cwd resolves
123
279
  to a project, and `tools.restrict({ deny })` is applied to that agent to deny
124
280
  every project server except those of the session's own project; a session
@@ -128,7 +284,13 @@ the markers is preserved byte-for-byte.
128
284
 
129
285
  ## Security boundary
130
286
 
131
- `stdio` lines in `.dsh/mcp.yml` spawn their `command` inside the dsh host
132
- process — project files are **executable code carriers**, so only add them in
133
- projects you trust. Lines that fail to mount or are invalid are skipped with a
134
- warning and do not affect other servers.
287
+ `stdio` lines in `.dsh/mcp.yml` (and `.mcp.json` rows mounted from it) spawn
288
+ their `command` inside the dsh host process — project files are **executable
289
+ code carriers**, so only add them in projects you trust. Lines that fail to
290
+ mount or are invalid are skipped with a warning and do not affect other
291
+ servers. CC's machine-wide `~/.claude.json` user layer is therefore **off by
292
+ default**: reading it opts foreign, environment-level servers into every
293
+ project's spawn set, which must be a deliberate `DSH_MCP_READ_CLAUDE_USER=1`.
294
+ The read-only allowlist exists precisely because that file also holds
295
+ credentials: nothing from it is ever written out, echoed into diagnostics, or
296
+ printed by the CLI.
package/docs/README.zh.md CHANGED
@@ -28,7 +28,7 @@ npm install -g deepseek-ai/dsh # 或从 GitHub 源码安装
28
28
  dsh plugin --profile web add dsh-project-mcp-manager@latest
29
29
 
30
30
  # 安装指定版本(版本号可先 npm view dsh-project-mcp-manager versions 查看)
31
- dsh plugin --profile web add dsh-project-mcp-manager@0.1.0
31
+ dsh plugin --profile web add dsh-project-mcp-manager@0.2.0
32
32
  ```
33
33
 
34
34
  **方式二:直接 pnpm 安装**(与方式一等价):
@@ -47,15 +47,14 @@ pnpm add link:<你的 dsh-mcp-project 源码目录> # 例如 D:\dev\dsh-mcp-pr
47
47
  ```
48
48
 
49
49
  **升级/锁定版本**:重跑方式一的 `add` 命令并带上目标版本后缀——`@latest`
50
- 升级到最新,`@0.1.0` 锁定到指定版本。
50
+ 升级到最新,`@0.2.0` 锁定到指定版本。
51
51
 
52
52
  ## 构建与测试
53
53
 
54
54
  ```powershell
55
55
  npm install
56
56
  npm run build # tsc → lib/
57
- node test/test-model.mjs
58
- node test/test-registry.mjs
57
+ npm test # node 直跑 test/ 下五个 .mjs(model / mcp-file / cc-file / registry / cli)
59
58
  ```
60
59
 
61
60
  ## 配置格式
@@ -87,6 +86,126 @@ node test/test-registry.mjs
87
86
  `transport` 支持 `stdio`(command/args/env/cwd)与 `streamable-http`
88
87
  (url/headers)。行加 `disabled: true` 即停用。标记之外的内容逐字节保留。
89
88
 
89
+ **与原生 cordis 方言的差异**:`!!js` 标签(profile 的 `cordis.patch.yml` 由
90
+ Loader 求值的 js-yaml 表达式,如官方 README 示例 `env: { TOKEN: !!js
91
+ process.env.GITHUB_TOKEN }`)在项目文件里**不支持**——受管块内出现未解析
92
+ 标签会使该文件整体报错跳过(写入 `.dsh/.mcp-diag.json` 并打日志),不会把
93
+ 表达式当字面量字符串静默装载。`env`/`headers` 的值 otherwise 是字面量,仅
94
+ `${VAR}` 引用会在装载时做串内插值(见下文「${VAR} 展开」);`disabled` 只能是
95
+ `true`/`false`。反之项目文件是超集语法:`env`/`headers` 允许 `KEY: null`
96
+ 表示删除该键(装载时被剔除),这在官方 mcp-client 校验里会被拒绝——把这类
97
+ 行原样挪回 `cordis.patch.yml` 会装载失败。
98
+
99
+ ## Claude Code 兼容层(只读)
100
+
101
+ 为顺应 CC 用户习惯,插件会**装载**以下两个 CC 配置位置,但从不写入它们。
102
+ 两者的默认姿态不同,因为风险等级不同:
103
+
104
+ - `<projectRoot>/.mcp.json` —— CC 的 project 层文件(`{ "mcpServers": { … } }`)。
105
+ 它是随仓库版本管理的项目自身声明:**默认开启**、只读。设
106
+ `DSH_MCP_IGNORE_MCP_JSON=1` 可整层停用。
107
+ - `~/.claude.json` —— 只读取**顶层 `mcpServers`** 子树,且**只在显式请求时**:
108
+ 设 `DSH_MCP_READ_CLAUDE_USER=1` 才读。这是 CC 的机器级用户状态,不属于任何
109
+ 项目:无条件读取会把每台这样的服务器扇出到**每个已知 dsh 项目**各起一个
110
+ 进程——这正是把它改为 opt-in 的事故根因。该文件其余内容一律不读入配置、
111
+ 不写、不打日志、不在任何输出里回显(严格 allowlist:oauth 凭据、项目历史、
112
+ UI 状态对本插件不可见)。
113
+
114
+ 边界与限制:
115
+
116
+ - **没有 local 作用域**。CC 的 `claude mcp add` 默认写进 `~/.claude.json` 的
117
+ `projects.<cwd>.mcpServers`(local 层),本插件不读取该层;请用
118
+ `dsh-mcp add --scope user` 或项目文件。
119
+ - `type: "sse"` 条目按条目报错跳过——装载后端(`dsh-mcp-client`)只支持
120
+ `stdio` 与 `streamable-http`。CC 的 `type: "http"` 与显式
121
+ `type: "streamable-http"` 都映射为 `streamable-http`;缺省 `type` 时,
122
+ 只带 `url` 不带 `command` 的条目按 http 处理,其余视为 stdio。
123
+ - stdio 的 `cwd`:项目层行的空 `cwd` 解析为项目根;用户层行
124
+ (`~/.dsh/mcp.yml` 与 `~/.claude.json`)解析为 dsh 宿主的工作目录。
125
+ 注意"用户层"不等于共享进程:用户层行仍**按每个已知项目各装一个进程**
126
+ (两个项目各开会话就是两个 fiber,按生效名规则改名为 `p<hash>_…`)——
127
+ 共享的只是 `cwd`,不是连接。
128
+ - 未知 CC 键容忍忽略;`enabled: false`(以及作为别名的 `disabled: true`)
129
+ 静默跳过该条目(不记诊断,也不占名)。
130
+ - 原生 yml 里 `disabled: true` 的行仍**占住影子键**(见下方去重规则):下层
131
+ 同名行一并被遮蔽不装载——禁用意味着"这个名字不许跑",而不是"让位给 CC
132
+ 副本"。CC 侧的 `enabled: false` 没有占位效果。推论:不删除、只在
133
+ `.mcp.json` 里关掉一条,同名的**用户层行会在该项目浮上来**装载;要把某台
134
+ 服务器在所有层压住,请在 `.dsh/mcp.yml` 留一条 `disabled: true` 占位行
135
+ (名字或归一化名字对上即可)。
136
+ - 坏文件/坏条目不影响其他服务器,且**按源隔离**:`.mcp.json` 坏了不会卸掉
137
+ 同项目的 yml 行(反之亦然);条目错误写入 `.dsh/.mcp-diag.json` 与宿主
138
+ 日志(诊断从不带文件内容)。只有 `.mcp.json`、没用过原生 yml 的项目,
139
+ 一旦有可报内容也会创建 `.dsh/` 目录。
140
+ - CC 每次会话都会重写 `~/.claude.json`;watcher 会对该文件做
141
+ `mcpServers` 子树的规范化哈希门控——子树没变就不触发对账。
142
+ - 共存边界开关(dsh 宿主环境变量):`DSH_MCP_READ_CLAUDE_USER=1` 显式启用
143
+ `~/.claude.json` 用户层;`DSH_MCP_IGNORE_MCP_JSON=1` 停用项目 `.mcp.json`
144
+ 层;旧开关 `DSH_MCP_IGNORE_CLAUDE_JSON=1` 为强制关闭用户层,**优先级高于
145
+ opt-in**(冲突时打一次「胜出」告警;旧开关将在后续版本移除)。用户层开启
146
+ 时,宿主一次性日志提示扇出规模(「N 条服务器将并入 M 个已知项目」),
147
+ 让多出来的 spawn 可解释、可回退。
148
+
149
+ **影子优先序**——按 1→4 先到先得合并,后到行与已收录行命中**三把键中的任何
150
+ 一把**即被遮蔽:精确 `serverName`;*归一化名称*(转小写去掉非字母数字后相同
151
+ ——`unityMCP` 与 `unity-mcp` 就是一台服务器的两种写法);*服务身份*
152
+ (`stdio` 取 command + args,Windows 下路径大小写不敏感;`streamable-http`
153
+ 取 url)。command/url 为空的行不注册身份键——`node a.js` 与 `node b.js` 是
154
+ 不同服务、绝不互杀——而 `disabled` 占位行三键全占、自身不装载。身份比对用的
155
+ 是**文件里的原始字符串,发生在 `${VAR}` 展开之前**,且 `env`、`headers`、
156
+ `cwd` **不参与**身份键:同一命令行、仅 env 不同的两台真不同服务器仍会被去重
157
+ (只留高优先级一条),同一台服务器一条写 `${VAR}`、一条写字面量则**不**互认。
158
+ 误剔时的处置:给被剔行改名(归一化后不同)或调整命令与参数。被遮蔽方写入
159
+ `.dsh/.mcp-diag.json`(`shadowedByYml` / `shadowedByProject` /
160
+ `shadowedIdentity`),身份/归一名去重剔除的每行还会在宿主日志告警「跳过重复
161
+ 服务定义」:
162
+
163
+ 1. `<projectRoot>/.dsh/mcp.yml`(原生格式,面板/CLI 管理)
164
+ 2. `<projectRoot>/.mcp.json`(CC project 层)
165
+ 3. `~/.dsh/mcp.yml`(原生用户层,见 CLI)
166
+ 4. `~/.claude.json` 顶层 `mcpServers`(CC user 层,opt-in)
167
+
168
+ 用户层行适用于所有已知项目,因此「某项目与用户层同名」(或两个项目同名)
169
+ 会走常规的生效名冲突改名规则(见工作原理)。
170
+
171
+ ## `${VAR}` 展开
172
+
173
+ 以上任一来源中,`command`、`args[*]`、`env[*]`、`cwd`、`url`、`headers[*]` 里的
174
+ `${VAR}` 引用(正则 `\$\{[A-Za-z_][A-Za-z0-9_]*\}`,允许出现在字符串任意
175
+ 位置)在装载时刻从 dsh 宿主进程环境做**串内插值**——与 Claude Code 同语义,
176
+ `"Authorization": "Bearer ${TOKEN}"` 这类写法可用。变量未设置**或为空串**时
177
+ 该行跳过装载,诊断记 `env-missing` 并只带变量名(绝不带值);需要保留字面
178
+ `${NAME}` 的写法目前不可表达。含引用的 `url` 在装载前的 schema 校验里任意
179
+ 位置都放行(包括 host 段,如 `https://${HOST}/mcp`)——合法性只在展开后判定:
180
+ 展开结果会再过一遍装载 schema 复验,产出非法配置(如 `${GATEWAY}/mcp` 拼出
181
+ 非 URL)时以 `env-invalid` 跳过,不把坏值递给装载后端。快照/行视图里
182
+ `fiberPhase` 保持装载生命周期枚举(未挂上的行是 `pending`),跳过原因走独立
183
+ 的 `skipReason` 字段(`env-missing` / `env-invalid` / `config-invalid` /
184
+ `plugin-throw`)。插件任何写路径都不落盘展开后的值;
185
+ CLI 写入时 `${VAR}` 原样保留——配置可以进 git,凭据留在环境里。
186
+
187
+ ## CLI:`dsh-mcp`
188
+
189
+ CC 风格的原生文件命令行管理(**只写** `.dsh/mcp.yml`——从不写
190
+ `.mcp.json` / `~/.claude.json`;不连接运行中的 dsh 宿主,宿主经文件监听自动
191
+ 收敛):
192
+
193
+ ```powershell
194
+ dsh-mcp add gitlab npx -y @modelcontextprotocol/server-gitlab -e GITLAB_TOKEN=${GITLAB_TOKEN}
195
+ dsh-mcp add --transport http sentry https://mcp.sentry.dev/mcp -H "Authorization: Bearer ${SENTRY_TOKEN}"
196
+ dsh-mcp add --scope user shared node ./tools/shared.js # 写 ~/.dsh/mcp.yml
197
+ dsh-mcp list # 四个来源全展示,带遮蔽标注
198
+ dsh-mcp get gitlab # 优先层条目;密钥值只显示键名
199
+ dsh-mcp remove gitlab # 只动原生 yml;命中只读层时给出编辑指引
200
+ ```
201
+
202
+ 作用域:`--scope project`(缺省,写最近 `.git` 祖先下的
203
+ `.dsh/mcp.yml`)与 `--scope user`(写 `~/.dsh/mcp.yml`,装载进每个项目)。
204
+ `add` 的 `cwd` 缺省随作用域而变:project 为 `"."`(项目根),user 为 `""`
205
+ (宿主目录);`-c` 显式覆盖。
206
+ 没有 `local` 作用域——`--scope local` 会报错并解释。`--transport` 接受
207
+ `stdio`(缺省)与 `http`;`sse` 拒绝(后端不支持)。
208
+
90
209
  ## 工作原理
91
210
 
92
211
  - **项目发现**:在线 agent 会话的 `session.header.cwd` + dsh 进程启动目录 →
@@ -95,13 +214,20 @@ node test/test-registry.mjs
95
214
  `@deepseek-ai/dsh-mcp-client` 实例(`ctx.plugin`),注册进全局工具层。
96
215
  同一项目内多会话共享同一连接。
97
216
  - **热重载**:chokidar 监听各项目根(depth 2,忽略 node_modules/.git/.hg/
98
- .svn),`.dsh/mcp.yml` 的增删改经 150ms 防抖触发全量对账:新增行装载、
99
- 删除行卸载、配置变化重装。
100
- - **生效名**:原始 `serverName` 在整个目录(全局行 + 全部项目行)中唯一时
217
+ .svn),但只有**已知项目根的精确配置文件**(`<projectRoot>/.dsh/mcp.yml`
218
+ 与 `<projectRoot>/.mcp.json`)的改动经 150ms 防抖触发全量对账:新增行
219
+ 装载、删除行卸载、配置变化重装。另有独立 watcher 以**两个精确文件路径**
220
+ 监听用户层:`~/.dsh/mcp.yml` 与 `~/.claude.json`(chokidar v5 对被监听的
221
+ 缺失文件能在其创建时补发事件,前提是父目录已存在)——不监听家目录整体。
222
+ `~/.claude.json` 事件只由规范化内容哈希门裁决(没有 size/mtime 快路径:
223
+ 同刻、同体积而内容不同的重写不能被吞掉)。
224
+ - **生效名**:原始 `serverName` 在整个目录(全局行 + 全部项目行;每个项目的
225
+ 行集合含遮蔽后幸存的用户层行)中唯一时
101
226
  保持原名;冲突时双方都改为 `p<sha256(项目根)前6位>_<原名>`(截断 32 字符,
102
227
  确定性、与装载顺序无关),避免 `dsh-mcp-client` 按进程根的 serverName
103
228
  预留冲突。全局行(profile `cordis.patch.yml` / bundle 层已装载的
104
- mcp-client 行)参与占用判定但不改名。
229
+ mcp-client 行)参与占用判定但不改名。模型可见的工具名由生效服务器名与
230
+ MCP 工具自身的名字拼成 `mcp__<生效名>__<工具名>`,与文件里写的 `serverName` 可能不同。
105
231
  - **会话可见性**:agent 创建时按其会话 cwd 解析项目,对该 agent 应用
106
232
  `tools.restrict({ deny })`,deny 掉除本会话项目外的全部项目服务器;会话
107
233
  无 cwd 时回退 owner 项目(子代理),再回退 dsh 进程 cwd 所在项目。会话
@@ -109,6 +235,10 @@ node test/test-registry.mjs
109
235
 
110
236
  ## 安全边界
111
237
 
112
- `.dsh/mcp.yml` 中的 `stdio` 行会在 dsh 宿主进程内 spawn 其 `command`——
113
- 项目文件是**可执行代码载体**,只应在可信项目中添加。装载失败/配置无效行
114
- 仅告警跳过,不影响其他服务器。
238
+ `.dsh/mcp.yml` 中的 `stdio` 行(以及经兼容层装载的 `.mcp.json` 行)会在 dsh
239
+ 宿主进程内 spawn 其 `command`——项目文件是**可执行代码载体**,只应在可信项目
240
+ 中添加。装载失败/配置无效行仅告警跳过,不影响其他服务器。CC 的机器级
241
+ `~/.claude.json` 用户层因此**默认关闭**:读取它等于把外来的环境级服务器塞进
242
+ 每个项目的 spawn 集合,必须是显式决定(`DSH_MCP_READ_CLAUDE_USER=1`)。
243
+ `~/.claude.json` 之所以同时按严格 allowlist 只读,正是因为该文件还存放凭据:
244
+ 其中未使用的部分不会被读出、写入任何文件,也不会在 CLI 或诊断输出里出现。
package/lib/cc-file.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * dsh-project-mcp-manager —— Claude Code MCP 兼容层(严格只读)。
3
+ *
4
+ * 两个来源:
5
+ * 1. `<projectRoot>/.mcp.json` —— CC 的 project scope(约定 commit 进 git);
6
+ * 2. `~/.claude.json` 顶层 `mcpServers` —— CC 的 user scope。
7
+ *
8
+ * 职责边界:`~/.claude.json` 是 CC 的单体状态文件(混杂登录凭证、onboarding
9
+ * 状态、每项目会话历史等,见 anthropics/claude-code#83143),本模块按严格
10
+ * allowlist **只摘取顶层 `mcpServers` 键**,其余内容解析后即弃,永不进入
11
+ * 行、诊断或日志;JSON parse 失败时诊断不带原始错误文本(V8 的消息会引用
12
+ * 文件内容片段)。兼容层永不写入,两个文件都只读。
13
+ *
14
+ * 明确不支持:
15
+ * - local scope(`~/.claude.json` 的 `projects[<cwd>].mcpServers`):其键按
16
+ * 会话 cwd 匹配,会把 cwd 粒度可见性引入项目粒度管线;`claude mcp add`
17
+ * 不带 `--scope` 默认写这里,文档指引用户改用 `--scope user` 或 `dsh-mcp`。
18
+ * - `type:"sse"` 条目:dsh-mcp-client 仅支持 stdio | streamable-http,
19
+ * entry-level 报错跳过。
20
+ *
21
+ * 行归一后与 `.dsh/mcp.yml` 受管行同构(PatchRow),复用主管线;`${VAR}`
22
+ * 占位保持字面值进配置,由 model.expandEnvRefs 在 mount 时运行时展开。
23
+ */
24
+ import { readFile } from "node:fs/promises";
25
+ import { createHash } from "node:crypto";
26
+ import { SERVER_NAME_RE, ccServerEntrySchema, mcpServerInputSchema, toPatchRow } from "./model.js";
27
+ /** CC project scope 文件名(位于项目根,与 .dsh 并列)。 */
28
+ export const CC_PROJECT_FILE = ".mcp.json";
29
+ /** CC user scope 单体状态文件名(位于家目录,allowlist 只读其顶层 mcpServers)。 */
30
+ export const CLAUDE_USER_FILE = ".claude.json";
31
+ /** 旧开关(保留一个版本):置为 "1" 时强制停用 ~/.claude.json 的读取与监听,
32
+ * 优先于 DSH_MCP_READ_CLAUDE_USER(两者同时设置时本开关胜出并告警一次)。
33
+ * TODO(v0.4):删除本开关——连同 claudeUserLayerConflict 的胜出分支、
34
+ * readUserLayer 的冲突告警闩、CLI 提示与文档中的旧开关说明。 */
35
+ export const IGNORE_CLAUDE_JSON_ENV = "DSH_MCP_IGNORE_CLAUDE_JSON";
36
+ /** 置为 "1" 才选择启用对 ~/.claude.json 顶层 mcpServers 的读取与监听。
37
+ * cc-user 层默认关闭:CC 用户级配置属于机器环境级外部状态,不应隐式挂进每个项目。 */
38
+ export const READ_CLAUDE_USER_ENV = "DSH_MCP_READ_CLAUDE_USER";
39
+ /** 置为 "1" 时跳过项目 .mcp.json(cc-project 层)的读取与监听;该层默认开启。 */
40
+ export const IGNORE_MCP_JSON_ENV = "DSH_MCP_IGNORE_MCP_JSON";
41
+ /** cc-user 层是否启用:须显式设 READ_CLAUDE_USER=1;IGNORE_CLAUDE_JSON 为强制关闭(胜出)。 */
42
+ export function claudeUserLayerEnabled(env = process.env) {
43
+ return env[READ_CLAUDE_USER_ENV] === "1" && env[IGNORE_CLAUDE_JSON_ENV] !== "1";
44
+ }
45
+ /** READ 与 IGNORE 同时置位:语义冲突,调用方应告警一次「IGNORE 胜出」。 */
46
+ export function claudeUserLayerConflict(env = process.env) {
47
+ return env[READ_CLAUDE_USER_ENV] === "1" && env[IGNORE_CLAUDE_JSON_ENV] === "1";
48
+ }
49
+ /** 项目 .mcp.json(cc-project 层)是否启用:默认开,IGNORE_MCP_JSON=1 关。 */
50
+ export function mcpJsonLayerEnabled(env = process.env) {
51
+ return env[IGNORE_MCP_JSON_ENV] !== "1";
52
+ }
53
+ /** 键排序的规范化 JSON 序列化(哈希与内容等价性判定共用)。 */
54
+ export function canonicalJsonString(value) {
55
+ return JSON.stringify(value, (_key, item) => {
56
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
57
+ // 码元序是刻意选择:serversHash 门要求跨环境稳定,locale 排序会改变既有哈希。
58
+ return Object.keys(item).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).reduce((acc, k) => {
59
+ acc[k] = item[k];
60
+ return acc;
61
+ }, {});
62
+ }
63
+ return item;
64
+ });
65
+ }
66
+ function isPlainObject(value) {
67
+ return value !== null && typeof value === "object" && !Array.isArray(value);
68
+ }
69
+ /**
70
+ * 单条 CC 条目 → 官方输入。stdio 的 cwd:project 层条目固定为项目根(CC spawn
71
+ * 于配置文件所在项目);user 层条目留空(继承宿主进程 cwd,文档披露的偏差——
72
+ * 同一用户行会被挂到多个项目,无法逐项目定 cwd)。
73
+ */
74
+ function ccEntryToInput(name, entry, projectRoot) {
75
+ if (entry.type === "sse") {
76
+ return { error: 'sse transport not supported(dsh-mcp-client 仅支持 stdio | streamable-http)' };
77
+ }
78
+ try {
79
+ // url 而无 type/command:按 http 处理(手写文件常见,CC 官方要求 type 但容忍度向实用倾斜)
80
+ const inferredHttp = entry.type === undefined && typeof entry.url === "string" && entry.command === undefined;
81
+ if (entry.type === "http" || entry.type === "streamable-http" || inferredHttp) {
82
+ if (typeof entry.url !== "string" || entry.url === "")
83
+ return { error: 'type:"http" 条目缺少 url' };
84
+ const input = mcpServerInputSchema.parse({
85
+ serverName: name,
86
+ transport: "streamable-http",
87
+ url: entry.url,
88
+ headers: entry.headers
89
+ });
90
+ return { input };
91
+ }
92
+ if (typeof entry.command !== "string" || entry.command === "") {
93
+ return { error: entry.type === "stdio" ? 'type:"stdio" 条目缺少 command' : "条目缺少 command(且无 type:\"http\"/url)" };
94
+ }
95
+ const input = mcpServerInputSchema.parse({
96
+ serverName: name,
97
+ transport: "stdio",
98
+ command: entry.command,
99
+ args: entry.args ?? [],
100
+ env: entry.env,
101
+ cwd: projectRoot
102
+ });
103
+ return { input };
104
+ }
105
+ catch (error) {
106
+ return { error: error instanceof Error ? error.message : String(error) };
107
+ }
108
+ }
109
+ /** CC 条目对象(mcpServers 的值)→ SourcedRow 列表;坏条目逐条报错跳过。 */
110
+ function parseMcpServersValue(mcpServers, source, projectRoot) {
111
+ const rows = [];
112
+ const entryErrors = [];
113
+ if (mcpServers === undefined || mcpServers === null)
114
+ return { rows, entryErrors };
115
+ if (!isPlainObject(mcpServers)) {
116
+ entryErrors.push("mcpServers 必须是对象(serverName → 配置)");
117
+ return { rows, entryErrors };
118
+ }
119
+ for (const [name, raw] of Object.entries(mcpServers)) {
120
+ // enabled:false / disabled:true 同等对待(后者在社区与 CC 讨论里同样常见,
121
+ // 写错键名却照样装载比不装载危险):静默不装载、不报错、不占名。
122
+ if (isPlainObject(raw) && (raw.enabled === false || raw.disabled === true))
123
+ continue;
124
+ if (!SERVER_NAME_RE.test(name)) {
125
+ entryErrors.push(`"${name}": serverName 非法(允许 1-32 位字母、数字、下划线或连字符)`);
126
+ continue;
127
+ }
128
+ const parsed = ccServerEntrySchema.safeParse(raw);
129
+ if (!parsed.success) {
130
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(";");
131
+ entryErrors.push(`"${name}": 条目字段无效:${detail}`);
132
+ continue;
133
+ }
134
+ const mapped = ccEntryToInput(name, parsed.data, projectRoot);
135
+ if ("error" in mapped) {
136
+ entryErrors.push(`"${name}": ${mapped.error}`);
137
+ continue;
138
+ }
139
+ rows.push({ rawName: name, row: toPatchRow(mapped.input), source });
140
+ }
141
+ return { rows, entryErrors };
142
+ }
143
+ /** 读取 JSON 文件并 parse;缺失返回 missing:true;parse 错误消息不含内容片段。 */
144
+ async function readJsonFile(path) {
145
+ let raw;
146
+ try {
147
+ raw = await readFile(path, "utf8");
148
+ }
149
+ catch (error) {
150
+ if (error?.code === "ENOENT")
151
+ return { missing: true };
152
+ return { error: "读取失败" };
153
+ }
154
+ try {
155
+ return { value: JSON.parse(raw) };
156
+ }
157
+ catch {
158
+ // V8 的 JSON.parse 错误消息会引用文件内容片段;这里必须只报类别。
159
+ return { error: "JSON 解析失败" };
160
+ }
161
+ }
162
+ /** 读项目根 `.mcp.json`(CC project scope)。文件缺失 → 空结果不算错误。 */
163
+ export async function readMcpJsonFile(path, projectRoot) {
164
+ const result = await readJsonFile(path);
165
+ if (result.missing === true)
166
+ return { rows: [], entryErrors: [] };
167
+ if (result.error !== undefined)
168
+ return { rows: [], entryErrors: [], fileError: `${CC_PROJECT_FILE} ${result.error}` };
169
+ if (!isPlainObject(result.value))
170
+ return { rows: [], entryErrors: [], fileError: `${CC_PROJECT_FILE} 顶层必须是 JSON 对象` };
171
+ if (!("mcpServers" in result.value))
172
+ return { rows: [], entryErrors: [], fileError: `${CC_PROJECT_FILE} 缺少 mcpServers 字段` };
173
+ const { rows, entryErrors } = parseMcpServersValue(result.value.mcpServers, "cc-project", projectRoot);
174
+ return { rows, entryErrors };
175
+ }
176
+ /**
177
+ * 读 `~/.claude.json` 的顶层 `mcpServers`(CC user scope,allowlist 摘取)。
178
+ * 文件缺失或未设 mcpServers → 空结果;同时输出 serversHash 供 watcher 门控
179
+ * (CC 每次会话都重写该文件,与 MCP 无关的状态变化不应触发 reconcile)。
180
+ */
181
+ export async function readClaudeUserFile(path) {
182
+ const result = await readJsonFile(path);
183
+ const emptyHash = createHash("sha256").update(canonicalJsonString({})).digest("hex");
184
+ if (result.missing === true)
185
+ return { rows: [], entryErrors: [], fileError: undefined, serversHash: emptyHash };
186
+ if (result.error !== undefined)
187
+ return { rows: [], entryErrors: [], fileError: `${CLAUDE_USER_FILE} ${result.error}` };
188
+ const servers = isPlainObject(result.value) ? result.value.mcpServers : undefined;
189
+ const serversHash = createHash("sha256").update(canonicalJsonString(servers ?? {})).digest("hex");
190
+ const { rows, entryErrors } = parseMcpServersValue(servers, "cc-user", "");
191
+ return { rows, entryErrors, serversHash };
192
+ }