@oh-agent/dsh-mcp-manager 0.1.1 → 0.1.2
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.en.md +364 -0
- package/README.md +127 -307
- package/lib/probe.js +1 -1
- package/package.json +2 -2
- package/README.zh.md +0 -225
package/README.en.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# @oh-agent/dsh-mcp-manager
|
|
2
|
+
|
|
3
|
+
English · [中文](README.md)
|
|
4
|
+
|
|
5
|
+
Management page for the MCP servers scattered across every local AI development
|
|
6
|
+
tool — one card per configuration file showing what it declares, one switch per
|
|
7
|
+
server that mounts it in DeepSeek Harness, and one probe of what actually
|
|
8
|
+
answers.
|
|
9
|
+
|
|
10
|
+
DeepSeek Harness → **Settings → MCP Manager** (between Skills Manager and
|
|
11
|
+
Market).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Install the bundle from the **Plugin Manager** page in Settings, or point
|
|
16
|
+
`plugin_manager` at the npm package:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.2"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
An absolute directory works as `target` too, which is how you install a local
|
|
23
|
+
checkout or the `dsh-mcp-manager.tgz` attached to the
|
|
24
|
+
[v0.1.2 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
|
|
25
|
+
|
|
26
|
+
Do not `pnpm add` the package into the profile by hand, and do not edit the
|
|
27
|
+
profile's `package.json` or `cordis.patch.yml`: installing a package is not the
|
|
28
|
+
same as selecting its bundle, so doing it manually leaves the row present but
|
|
29
|
+
never mounted.
|
|
30
|
+
|
|
31
|
+
Either way the bundle adds one settings tab and one agent tool (`mcp_manage`);
|
|
32
|
+
it declares no runtime dependencies, so there is nothing to build and no
|
|
33
|
+
`allowBuilds` step.
|
|
34
|
+
|
|
35
|
+
The page reads other tools' configuration files but never needs write access to
|
|
36
|
+
them for the switch. Nothing leaves the machine — the only outbound traffic is a
|
|
37
|
+
probe you ask for, and only to the server you asked about.
|
|
38
|
+
|
|
39
|
+
## The problem it solves
|
|
40
|
+
|
|
41
|
+
An MCP server is declared in a different file, under a different key, in a
|
|
42
|
+
different dialect for every tool:
|
|
43
|
+
|
|
44
|
+
| tool | file (Windows) | top-level key |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` |
|
|
47
|
+
| Claude Code | `~/.claude.json`, `<proj>/.mcp.json` | `mcpServers` |
|
|
48
|
+
| Cursor | `~/.cursor/mcp.json` | `mcpServers` |
|
|
49
|
+
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
|
|
50
|
+
| VS Code | `<ws>/.vscode/mcp.json` | `servers` |
|
|
51
|
+
| Roo Code | `…\rooveterinaryinc.roo-cline\settings\cline_mcp_settings.json` | `mcpServers` |
|
|
52
|
+
| Gemini CLI | `~/.gemini/settings.json` | `mcpServers` |
|
|
53
|
+
| Qwen Code | `~/.qwen/settings.json` | `mcpServers` |
|
|
54
|
+
| Qoder IDE | `%APPDATA%\Qoder\SharedClientCache\mcp.json` | `mcpServers` |
|
|
55
|
+
| OpenCode | `~/.config/opencode/opencode.json` | `mcp` |
|
|
56
|
+
| Trae | `%APPDATA%\Trae\User\mcp.json` | `mcpServers` |
|
|
57
|
+
| Trae CN | `%APPDATA%\Trae CN\User\mcp.json` | `mcpServers` |
|
|
58
|
+
| Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` |
|
|
59
|
+
| Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` |
|
|
60
|
+
| Zed | `settings.json` | `context_servers` |
|
|
61
|
+
| Continue | `~/.continue/config.yaml` | `mcpServers` (YAML array) |
|
|
62
|
+
| CodeBuddy | `%APPDATA%\CodeBuddy\...` | `mcpServers` |
|
|
63
|
+
| **DeepSeek Harness** | profile composition | `@deepseek-ai/dsh-mcp-client` rows |
|
|
64
|
+
|
|
65
|
+
The differences are not cosmetic and they are where hand-editing goes wrong:
|
|
66
|
+
|
|
67
|
+
- **Remote transport is spelled differently.** Windsurf only accepts
|
|
68
|
+
`serverUrl` — a `url` key is silently ignored. Gemini and Qwen distinguish
|
|
69
|
+
`url` (SSE) from `httpUrl` (streamable HTTP), while Roo spells streamable
|
|
70
|
+
HTTP `streamable-http`.
|
|
71
|
+
- **Timeouts change unit.** Millisecond fields in Gemini/Qwen/DSH, seconds in
|
|
72
|
+
Roo.
|
|
73
|
+
- **Commands differ in shape.** OpenCode takes `command` as an array and `env`
|
|
74
|
+
as `environment`; everything else takes a string plus a separate `args`.
|
|
75
|
+
- **Some files are shared with unrelated state.** `~/.claude.json` also holds
|
|
76
|
+
history and OAuth tokens; `opencode.json` holds providers and plugins. A
|
|
77
|
+
whole-file rewrite destroys them.
|
|
78
|
+
|
|
79
|
+
This plugin reads all of it into one model and writes back only the entry it
|
|
80
|
+
means to change.
|
|
81
|
+
|
|
82
|
+
### Windows and macOS
|
|
83
|
+
|
|
84
|
+
Nothing is hard-coded to one platform. Each adapter declares its file through a
|
|
85
|
+
resolver that reads the OS at module load:
|
|
86
|
+
|
|
87
|
+
| root | Windows | macOS |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `appData()` | `%APPDATA%` | `~/Library/Application Support` |
|
|
90
|
+
| `xdgConfig()` | `~/.config` | `~/.config` |
|
|
91
|
+
| `vscodeUserDir()` | `%APPDATA%\Code\User` | `~/Library/Application Support/Code/User` |
|
|
92
|
+
| `zedConfig()` | `%APPDATA%\Zed` | `~/.config/zed` |
|
|
93
|
+
| `HOME`-relative | `~/.codex`, `~/.cursor`, … | identical |
|
|
94
|
+
|
|
95
|
+
Zed is the exception worth naming: on macOS it reads `~/.config/zed`, **not**
|
|
96
|
+
Application Support, so it gets its own resolver. `APPDATA`, `XDG_CONFIG_HOME`
|
|
97
|
+
and `COPILOT_HOME` are honoured when set.
|
|
98
|
+
Project-scope paths are always joined onto the workspace root, so they are
|
|
99
|
+
portable by construction. `scripts/test-paths.mjs` loads the real adapter source
|
|
100
|
+
under a simulated `win32`, `darwin` and `linux` and asserts the resolved
|
|
101
|
+
directory for every client.
|
|
102
|
+
|
|
103
|
+
The file table above is written the way Windows shows it (`%APPDATA%`, `\`); on
|
|
104
|
+
macOS the same rows resolve under `~/Library/Application Support/`. The scanner
|
|
105
|
+
reports the real absolute path it found, so the page always shows the truth for
|
|
106
|
+
the machine it is running on.
|
|
107
|
+
|
|
108
|
+
## Architecture
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
client.js (settings page) index.js (host entry)
|
|
112
|
+
│ │
|
|
113
|
+
└──────── /api/dsh-mcp-manager ────┘
|
|
114
|
+
│ │
|
|
115
|
+
scan.js ops.js probe.js
|
|
116
|
+
│ │ │
|
|
117
|
+
adapters.js store.js surgical.js
|
|
118
|
+
│ │ │
|
|
119
|
+
canonical.js ledger+ byte-preserving
|
|
120
|
+
(one model) backups JSON/TOML/YAML
|
|
121
|
+
│
|
|
122
|
+
dsh.js + dsh-patch.js + toggle.js
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **`canonical.js` — the single semantic model.** Every client entry decodes to
|
|
126
|
+
`{name, transport, command, args, env, url, headers, timeoutMs, enabled,
|
|
127
|
+
autoApprove, extra, secrets}`. Capabilities are *not* flattened to the lowest
|
|
128
|
+
common denominator: a field a client cannot express is dropped on write and
|
|
129
|
+
reported as `droppedFields`, never silently invented.
|
|
130
|
+
- **`adapters.js` — the only format-aware layer.** Eighteen clients described as
|
|
131
|
+
data: container key, dialect (which key means url, which type strings map to
|
|
132
|
+
which transport, timeout unit), capabilities, and where the file lives.
|
|
133
|
+
- **`surgical.js` — byte-preserving editors.** Offset-carrying parse trees and
|
|
134
|
+
bottom-up edits, so an entry can be replaced without reformatting the file.
|
|
135
|
+
Comments, trailing commas, CRLF, and unrelated keys survive; unparsable input
|
|
136
|
+
is refused rather than rewritten.
|
|
137
|
+
- **`store.js` — the safety net.** A backup of the pre-write bytes, a
|
|
138
|
+
compare-and-set hash check, and an ownership ledger.
|
|
139
|
+
- **`probe.js` — does it actually work.** A hand-written JSON-RPC client (no
|
|
140
|
+
dependency on `@modelcontextprotocol/sdk`): spawn stdio → `initialize` →
|
|
141
|
+
`notifications/initialized` → `tools/list`; for remote, POST with
|
|
142
|
+
`Accept: application/json, text/event-stream` and follow an SSE response.
|
|
143
|
+
Read-only — it never invokes a tool.
|
|
144
|
+
- **`dsh.js` / `dsh-patch.js` / `toggle.js` — Harness itself as a first-class
|
|
145
|
+
target.** Existing rows are rewritten through `configEditor.edit()` (validated,
|
|
146
|
+
hot-reloaded). A brand-new server has no row to address, so it goes into the
|
|
147
|
+
composition's `- insert:` patch layer, where the Loader picks it up on its own
|
|
148
|
+
— the operation waits for that row and reports `live: true`.
|
|
149
|
+
If the budget expires first it reports `activating: true`, **never** "restart
|
|
150
|
+
required": a patch row is only written where the Loader can see it, so it always
|
|
151
|
+
arrives eventually, and telling the user to restart for a server that is already
|
|
152
|
+
coming up was measured to be wrong here (mounting several servers back to back
|
|
153
|
+
pushed reconciliation past any fixed budget, then all of them appeared live).
|
|
154
|
+
`toggle.js` is the mount switch: a live row goes through
|
|
155
|
+
`pluginManager.setPluginEnabled`, an unmounted one gets the same
|
|
156
|
+
`disabled: true` override item the plugin manager writes, and a server that
|
|
157
|
+
lives only in another tool is first written to the patch layer.
|
|
158
|
+
|
|
159
|
+
Discovery reads `entry.options.name`, not `entry.name`: a live Loader entry
|
|
160
|
+
has no `name` property of its own (verified against a running Host), and
|
|
161
|
+
checking the wrong field silently matches zero rows — which made every mounted
|
|
162
|
+
server look unreconciled and demand a restart that was never required.
|
|
163
|
+
|
|
164
|
+
## Safety model
|
|
165
|
+
|
|
166
|
+
The write path is built around one rule: **only rewrite what we can prove we
|
|
167
|
+
wrote.**
|
|
168
|
+
|
|
169
|
+
1. **Ownership ledger.** Every write records
|
|
170
|
+
`{path, serverName, fingerprint}`. A later write of the same name is applied
|
|
171
|
+
only if the definition on disk still matches the fingerprint — the user has
|
|
172
|
+
not edited it in the meantime.
|
|
173
|
+
2. **A foreign server is never clobbered.** A name already taken by an entry we
|
|
174
|
+
did not write is treated as the user's: the incoming server gets a suffixed
|
|
175
|
+
name instead.
|
|
176
|
+
3. **Optimistic concurrency.** The caller passes the hash it scanned; a changed
|
|
177
|
+
file returns `409` with the current hash, and the UI offers a re-scan. Silent
|
|
178
|
+
last-write-wins is not possible.
|
|
179
|
+
4. **Backup before every mutation**, retained per the `backupsToKeep` config,
|
|
180
|
+
restorable from the page.
|
|
181
|
+
5. **Atomic replace.** Write a temp file, `rename` over the target, then read
|
|
182
|
+
back and re-parse to prove the result is valid.
|
|
183
|
+
6. **Credentials are masked before they leave the host.** Masking is
|
|
184
|
+
key-driven, not value-driven — a short token (`TOKEN=abc`) is still masked,
|
|
185
|
+
while an indirection (`${FIGMA_API_KEY}`) is shown as-is because it names a
|
|
186
|
+
secret rather than carrying one. It covers every place a literal hides: env,
|
|
187
|
+
headers, unmodelled `extra`, the raw entry copy, and command-line flags
|
|
188
|
+
(`--figma-api-key=figd_…`, which is the documented invocation for several
|
|
189
|
+
servers and is not an `env` entry at all). `reveal` is an explicit,
|
|
190
|
+
confirmed action.
|
|
191
|
+
7. **Literal credentials are refused in project scope.** Writing a plaintext
|
|
192
|
+
key into a file destined for a repository fails with a hint to use an
|
|
193
|
+
environment reference.
|
|
194
|
+
8. **A masked definition can never be written back.** The browser only holds
|
|
195
|
+
the redacted copy, so every write resolves the definition from the host-side
|
|
196
|
+
scan; if a mask (`figd••••0000`) still reaches a write path it is refused
|
|
197
|
+
with `error.server.masked` instead of replacing a real credential with
|
|
198
|
+
bullets.
|
|
199
|
+
9. **Mutation requests need a custom header** (`x-dsh-mcp-manager: 1`) on top
|
|
200
|
+
of the JSON content-type and the loopback-origin check — a cross-site form
|
|
201
|
+
cannot set it.
|
|
202
|
+
|
|
203
|
+
## The switch mounts into DeepSeek Harness — and nothing else
|
|
204
|
+
|
|
205
|
+
The one control on each row is a switch, and it has exactly one meaning: **is
|
|
206
|
+
this server mounted in DeepSeek Harness?**
|
|
207
|
+
|
|
208
|
+
Turning it on mounts the server here. Turning it off unmounts it. The file the
|
|
209
|
+
definition came from is never opened for writing by the switch — verified by
|
|
210
|
+
hashing every client config before and after a mount/unmount cycle on this
|
|
211
|
+
machine, all unchanged.
|
|
212
|
+
|
|
213
|
+
That restriction is not squeamishness, it is what the formats force. Most clients
|
|
214
|
+
have no enable field at all: Qoder's and Trae's `mcp.json` carry no enable-ish key
|
|
215
|
+
whatsoever. Writing one would be a switch that lies.
|
|
216
|
+
|
|
217
|
+
How the mount works, by case:
|
|
218
|
+
|
|
219
|
+
| the server is | the switch does | takes effect |
|
|
220
|
+
| --- | --- | --- |
|
|
221
|
+
| an `- insert:` item we wrote | deletes the item on off, rewrites it on on | on reconcile; the file returns to its exact previous bytes |
|
|
222
|
+
| already a live `dsh-mcp-client` row | `pluginManager.setPluginEnabled(rowId, …)` | instantly — the row unmounts and its tools leave the tool list |
|
|
223
|
+
| an `- insert:` item the user wrote | appends/removes the same `- id: …` / `disabled: true` override the plugin manager itself writes | on reconcile |
|
|
224
|
+
| declared in another tool only, and switched on | writes a new `- insert:` row | live as soon as the Loader reconciles it (usually 1–4 s); the operation waits up to 8 s and reports `live: true`, else `activating: true` |
|
|
225
|
+
| not mounted anywhere, and switched off | nothing — already true, no dangling override | — |
|
|
226
|
+
|
|
227
|
+
Our own row is removed before the Loader path is tried: the plugin manager can
|
|
228
|
+
dispose a row hot, but it does so by appending a `disabled: true` override beside
|
|
229
|
+
the definition, which would leave both our row and that override in the user's
|
|
230
|
+
composition permanently. Removing the insert item is both the unmount and the
|
|
231
|
+
cleanup.
|
|
232
|
+
|
|
233
|
+
Because the override is a separate item and not a rewrite, and because a row we
|
|
234
|
+
wrote is removed rather than disabled, a full mount → unmount cycle returns
|
|
235
|
+
`cordis.patch.yml` to its exact previous bytes.
|
|
236
|
+
|
|
237
|
+
### A mount name is not always the display name
|
|
238
|
+
|
|
239
|
+
`@deepseek-ai/dsh-mcp-client` requires `serverName` to match
|
|
240
|
+
`/^[A-Za-z0-9_-]{1,32}$/`. Many real servers do not — this machine has a dozen
|
|
241
|
+
named `表单管理系统 - API 文档` and friends, and `Figma AI Bridge` has a space.
|
|
242
|
+
Squeezing those through `sanitizeName` alone gives `API` for every Chinese name,
|
|
243
|
+
so a second mount would collide with the first and the switch would flip the wrong
|
|
244
|
+
server.
|
|
245
|
+
|
|
246
|
+
`canonical.js` therefore exports `dshMountName(name)`: sanitize, and if the result
|
|
247
|
+
lost information (CJK characters, spaces, over-length) append a short hash of the
|
|
248
|
+
original — `表单管理系统 - API 文档` → `API-c2f500c4`, `Figma AI Bridge` →
|
|
249
|
+
`Figma-AI-Bridge`. It is pure and total, so the write path, the scan, the toggle
|
|
250
|
+
and the removal all compute the same string from the same input without storing a
|
|
251
|
+
mapping anywhere.
|
|
252
|
+
|
|
253
|
+
Consequences the scan and the page handle:
|
|
254
|
+
|
|
255
|
+
- the node keeps its display name; `mountedAs` carries the row name and `entryId`
|
|
256
|
+
the Loader id, so the switch finds its row under the mapped name;
|
|
257
|
+
- the server appears under the DSH file card with its display name, and the
|
|
258
|
+
original tools' cards are unchanged;
|
|
259
|
+
- two servers that sanitize to the same base still get distinct row names (the
|
|
260
|
+
hash differs), so no server silently replaces another.
|
|
261
|
+
|
|
262
|
+
`scripts/test-mount-name.mjs` pins the mapping, including the collision case and
|
|
263
|
+
the round trip through scan.
|
|
264
|
+
|
|
265
|
+
## Using it
|
|
266
|
+
|
|
267
|
+
The page is laid out like the skills page it sits next to: a title row with the
|
|
268
|
+
actions, a four-cell summary strip (servers · files · mounted in DSH · literal
|
|
269
|
+
credentials), a filter row, then one bordered card per config file.
|
|
270
|
+
|
|
271
|
+
Each card lists the servers that file declares — headed by the tool name, the
|
|
272
|
+
server count and the path (shortened to `%APPDATA%\…` style, full path in the
|
|
273
|
+
tooltip). A card collapses; while a filter is active every card opens, because a
|
|
274
|
+
match hidden inside a collapsed card reads as "no results". Every row shows the
|
|
275
|
+
transport, the command or URL, how many tools share the definition, its probe
|
|
276
|
+
result, and the mount switch.
|
|
277
|
+
|
|
278
|
+
The filters are a free-text search (name, command, path) plus two selects: by
|
|
279
|
+
tool, and by mount state (mounted / not mounted).
|
|
280
|
+
|
|
281
|
+
Clicking a row opens a dialog rather than expanding it in place — inline
|
|
282
|
+
expansion reflows every row below and made the card look like two stacked cards.
|
|
283
|
+
The dialog lists every file that declares the server, the canonical definition,
|
|
284
|
+
a paste-ready YAML row for a cordis composition, and "copy to…" for pushing the
|
|
285
|
+
definition into another tool.
|
|
286
|
+
|
|
287
|
+
The same operations are exposed to the agent as the `mcp_manage` tool, so the
|
|
288
|
+
page and the agent share one implementation and cannot diverge:
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Configuration
|
|
295
|
+
|
|
296
|
+
`cordis.patch.yml` row config:
|
|
297
|
+
|
|
298
|
+
| key | default | meaning |
|
|
299
|
+
| --- | --- | --- |
|
|
300
|
+
| `includeProjectScope` | `true` | also scan workspace-level files |
|
|
301
|
+
| `probeTimeoutMs` | `20000` | per-server probe budget |
|
|
302
|
+
| `probeConcurrency` | `4` | parallel probes |
|
|
303
|
+
| `backupsToKeep` | `20` | retained backups per file |
|
|
304
|
+
|
|
305
|
+
State (ledger + backups) lives in `<DSH_HOME>/mcp-manager/`.
|
|
306
|
+
|
|
307
|
+
## Verified on this machine
|
|
308
|
+
|
|
309
|
+
Eight of eighteen clients installed, 27 servers discovered across Qoder IDE,
|
|
310
|
+
OpenCode, Trae, Trae CN and DSH. Context7 probes OK with 2 tools,
|
|
311
|
+
`playwright_mcp` with 25. A DSH server mounted and unmounted through the page
|
|
312
|
+
leaves `cordis.patch.yml` **byte-identical** to its pre-write hash, with the
|
|
313
|
+
user's own provider, permission and agency entries untouched — and every other
|
|
314
|
+
client configuration file keeps its hash through the same cycle.
|
|
315
|
+
|
|
316
|
+
Twenty-one plaintext credentials were found on disk — most in `env` maps and
|
|
317
|
+
one in a command-line flag (`--figma-api-key` in Qoder's `mcp.json`). None of
|
|
318
|
+
their literals appears anywhere in the state payload; the page shows only key
|
|
319
|
+
names, and the write path refuses a masked value.
|
|
320
|
+
|
|
321
|
+
Thirteen test suites under `scripts/` cover the editors, the path layer, the write
|
|
322
|
+
path, the DSH adapter, the patch layer, the mount switch, the mount-name mapping
|
|
323
|
+
for non-ASCII server names, redaction and the masked-write guard, plus an
|
|
324
|
+
end-to-end pass that asserts no credential literal reaches the client payload:
|
|
325
|
+
|
|
326
|
+
```
|
|
327
|
+
node scripts/test-surgical.mjs 17 passing
|
|
328
|
+
node scripts/test-write.mjs 12 passing
|
|
329
|
+
node scripts/test-dsh.mjs 16 passing
|
|
330
|
+
node scripts/test-dsh-patch.mjs 15 passing
|
|
331
|
+
node scripts/test-dsh-e2e.mjs 8 passing
|
|
332
|
+
node scripts/test-toggle.mjs 16 passing
|
|
333
|
+
node scripts/test-redact.mjs 14 passing
|
|
334
|
+
node scripts/test-masked-write.mjs 5 passing
|
|
335
|
+
node scripts/test-client-load.mjs 28 passing
|
|
336
|
+
node scripts/test-paths.mjs 16 passing
|
|
337
|
+
node scripts/test-mount-name.mjs 12 passing
|
|
338
|
+
node scripts/test-locale.mjs en/zh key parity
|
|
339
|
+
node scripts/test-release.mjs 11 passing
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Known limits
|
|
343
|
+
|
|
344
|
+
- **Install detection is file presence.** An installed tool with no MCP config
|
|
345
|
+
yet is indistinguishable from an absent one until its directory exists.
|
|
346
|
+
- **A newly mounted DSH server relies on the Loader reconciling it.** The
|
|
347
|
+
operation waits up to 8 s for the row and reports `live: true`; if that window
|
|
348
|
+
expires it reports `activating: true` and the page re-reads the state a moment
|
|
349
|
+
later. No restart is ever demanded for a row the Loader can see.
|
|
350
|
+
- **A row we did not write is switched off, not removed.** Deleting a definition
|
|
351
|
+
the user pasted would destroy their work, so such a row gets the same
|
|
352
|
+
`disabled: true` override the plugin manager writes, and stays in the file.
|
|
353
|
+
- **Only DSH is switchable.** The other tools' files are read and written
|
|
354
|
+
explicitly, never toggled — see the section above.
|
|
355
|
+
- **Continue is read-only** (YAML array shape); editing it is planned.
|
|
356
|
+
- **Secrets are not migrated to a vault.** The plugin masks and warns; moving
|
|
357
|
+
credentials into `${env:...}` references stays the user's decision.
|
|
358
|
+
- **The MCP registry is not consulted.** `registry.modelcontextprotocol.io` is
|
|
359
|
+
explicitly preview and not intended for direct host consumption, so server
|
|
360
|
+
discovery is by scanning local files only.
|
|
361
|
+
|
|
362
|
+
## Licence
|
|
363
|
+
|
|
364
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
# @oh-agent/dsh-mcp-manager
|
|
2
2
|
|
|
3
|
-
[English](
|
|
3
|
+
**中文** · [English](README.en.md)
|
|
4
4
|
|
|
5
|
-
一个设置页,管理散落在本机各个 AI 开发工具里的 MCP
|
|
6
|
-
|
|
7
|
-
同一个 MCP 服务器,在 Claude Code 里写在 `~/.claude.json`,在 Cursor 里写在
|
|
8
|
-
`~/.cursor/mcp.json`,在 VS Code 里叫 `servers` 而不是 `mcpServers`,在 OpenCode 里
|
|
9
|
-
`command` 又变成了数组。这个插件把 18 个工具各自的配置文件读出来,按「文件」分组展示 ——
|
|
10
|
-
每个配置文件一张卡片,点开看它声明了哪些服务器、每个服务器用什么启动命令、哪些字段带着明文令牌。
|
|
11
|
-
每个服务器给一个开关,把它挂进 DeepSeek Harness;再给一次真连探活,看它到底能不能启动、
|
|
12
|
-
手里有哪些工具。
|
|
13
|
-
|
|
14
|
-
**开关只作用于 DeepSeek Harness,不改动任何其他工具的配置文件。** 这是整个插件最重要的一条约束。
|
|
5
|
+
一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来。
|
|
15
6
|
|
|
16
7
|
入口:DeepSeek Harness → **设置 → MCP**(位于「技能管理」和「插件市场」之间)。
|
|
17
8
|
|
|
@@ -23,76 +14,31 @@
|
|
|
23
14
|
|
|
24
15
|
**方式二:让 agent 安装**
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
直接说「安装 @oh-agent/dsh-mcp-manager」,等价于:
|
|
27
18
|
|
|
28
19
|
```
|
|
29
|
-
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.
|
|
20
|
+
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.2"
|
|
30
21
|
```
|
|
31
22
|
|
|
32
23
|
`target` 也可以填一个绝对目录 —— 本地源码目录,或者
|
|
33
|
-
[v0.1.
|
|
24
|
+
[v0.1.2 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
|
|
34
25
|
`dsh-mcp-manager.tgz` 解出来的目录。
|
|
35
26
|
|
|
36
27
|
**不要**手动 `pnpm add` 进 profile,也不要手改 profile 的 `package.json` 或
|
|
37
28
|
`cordis.patch.yml`:装了包不等于选中了它的 bundle,手动装的后果是那一行存在但永远不会被挂载。
|
|
38
29
|
|
|
39
|
-
安装后增加一个设置页和一个 agent 工具(`mcp_manage
|
|
40
|
-
也没有 `allowBuilds` 授权步骤。要求 Node `^22.19.0 || >=24.0.0`(DeepSeek Harness 自带
|
|
41
|
-
Node 24,无需另装)。
|
|
42
|
-
|
|
43
|
-
页面会读取其他工具的配置文件,但**开关不需要对它们有写权限**。所有数据都不离开本机 ——
|
|
44
|
-
唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
|
|
45
|
-
|
|
46
|
-
完整中文文档(支持的工具清单、方言差异、安全模型、已知限制)见
|
|
47
|
-
[README.zh.md](README.zh.md)。
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## English
|
|
30
|
+
安装后增加一个设置页和一个 agent 工具(`mcp_manage`)。它没有任何运行时依赖,不需要构建,也没有 `allowBuilds` 授权步骤。要求 Node `^22.19.0 || >=24.0.0`(DeepSeek Harness 自带 Node 24,无需另装)。
|
|
52
31
|
|
|
53
|
-
|
|
54
|
-
tool — one card per configuration file showing what it declares, one switch per
|
|
55
|
-
server that mounts it in DeepSeek Harness, and one probe of what actually
|
|
56
|
-
answers.
|
|
57
|
-
|
|
58
|
-
DeepSeek Harness → **Settings → MCP Manager** (between Skills Manager and
|
|
59
|
-
Market).
|
|
60
|
-
|
|
61
|
-
## Install
|
|
62
|
-
|
|
63
|
-
Install the bundle from the **Plugin Manager** page in Settings, or point
|
|
64
|
-
`plugin_manager` at the npm package:
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.1"
|
|
68
|
-
```
|
|
32
|
+
页面会读取其他工具的配置文件,但**开关不需要对它们有写权限**。所有数据都不离开本机 —— 唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
|
|
69
33
|
|
|
70
|
-
|
|
71
|
-
checkout or the `dsh-mcp-manager.tgz` attached to the
|
|
72
|
-
[v0.1.1 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
|
|
34
|
+
## 它解决什么问题
|
|
73
35
|
|
|
74
|
-
|
|
75
|
-
profile's `package.json` or `cordis.patch.yml`: installing a package is not the
|
|
76
|
-
same as selecting its bundle, so doing it manually leaves the row present but
|
|
77
|
-
never mounted.
|
|
36
|
+
同一个 MCP 服务器,在每个工具里声明在不同的文件、不同的键、不同的方言下:
|
|
78
37
|
|
|
79
|
-
|
|
80
|
-
it declares no runtime dependencies, so there is nothing to build and no
|
|
81
|
-
`allowBuilds` step.
|
|
82
|
-
|
|
83
|
-
The page reads other tools' configuration files but never needs write access to
|
|
84
|
-
them for the switch. Nothing leaves the machine — the only outbound traffic is a
|
|
85
|
-
probe you ask for, and only to the server you asked about.
|
|
86
|
-
|
|
87
|
-
## The problem it solves
|
|
88
|
-
|
|
89
|
-
An MCP server is declared in a different file, under a different key, in a
|
|
90
|
-
different dialect for every tool:
|
|
91
|
-
|
|
92
|
-
| tool | file (Windows) | top-level key |
|
|
38
|
+
| 工具 | 文件(Windows) | 顶层键 |
|
|
93
39
|
| --- | --- | --- |
|
|
94
40
|
| Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` |
|
|
95
|
-
| Claude Code | `~/.claude.json
|
|
41
|
+
| Claude Code | `~/.claude.json`、`<proj>/.mcp.json` | `mcpServers` |
|
|
96
42
|
| Cursor | `~/.cursor/mcp.json` | `mcpServers` |
|
|
97
43
|
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
|
|
98
44
|
| VS Code | `<ws>/.vscode/mcp.json` | `servers` |
|
|
@@ -106,57 +52,39 @@ different dialect for every tool:
|
|
|
106
52
|
| Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` |
|
|
107
53
|
| Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` |
|
|
108
54
|
| Zed | `settings.json` | `context_servers` |
|
|
109
|
-
| Continue | `~/.continue/config.yaml` | `mcpServers
|
|
55
|
+
| Continue | `~/.continue/config.yaml` | `mcpServers`(YAML 数组) |
|
|
110
56
|
| CodeBuddy | `%APPDATA%\CodeBuddy\...` | `mcpServers` |
|
|
111
|
-
| **DeepSeek Harness** | profile
|
|
57
|
+
| **DeepSeek Harness** | profile 组合文件 | `@deepseek-ai/dsh-mcp-client` 行 |
|
|
112
58
|
|
|
113
|
-
|
|
59
|
+
这些差异不是美观问题,而是手改最容易翻车的地方:
|
|
114
60
|
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
- **Timeouts change unit.** Millisecond fields in Gemini/Qwen/DSH, seconds in
|
|
120
|
-
Roo.
|
|
121
|
-
- **Commands differ in shape.** OpenCode takes `command` as an array and `env`
|
|
122
|
-
as `environment`; everything else takes a string plus a separate `args`.
|
|
123
|
-
- **Some files are shared with unrelated state.** `~/.claude.json` also holds
|
|
124
|
-
history and OAuth tokens; `opencode.json` holds providers and plugins. A
|
|
125
|
-
whole-file rewrite destroys them.
|
|
61
|
+
- **远程传输的拼写不同。** Windsurf 只认 `serverUrl`,写成 `url` 会被静默忽略。Gemini 和 Qwen 区分 `url`(SSE)与 `httpUrl`(streamable HTTP),而 Roo 把 streamable HTTP 拼成 `streamable-http`。
|
|
62
|
+
- **超时单位不同。** Gemini / Qwen / DSH 是毫秒,Roo 是秒。
|
|
63
|
+
- **命令的形态不同。** OpenCode 的 `command` 是数组,`env` 叫 `environment`;其他工具是字符串 `command` 加独立的 `args`。
|
|
64
|
+
- **有些文件混着无关状态。** `~/.claude.json` 里还有历史记录和 OAuth token;`opencode.json` 里有 provider 和 plugin。整文件重写会把这些一起毁掉。
|
|
126
65
|
|
|
127
|
-
|
|
128
|
-
means to change.
|
|
66
|
+
本插件把这一切读入同一个模型,写回时只改它想改的那一条。
|
|
129
67
|
|
|
130
|
-
### Windows
|
|
68
|
+
### Windows 与 macOS
|
|
131
69
|
|
|
132
|
-
|
|
133
|
-
resolver that reads the OS at module load:
|
|
70
|
+
没有任何路径写死在单一平台上。每个适配器通过读取操作系统的解析器声明自己的文件:
|
|
134
71
|
|
|
135
|
-
|
|
|
72
|
+
| 根 | Windows | macOS |
|
|
136
73
|
| --- | --- | --- |
|
|
137
74
|
| `appData()` | `%APPDATA%` | `~/Library/Application Support` |
|
|
138
75
|
| `xdgConfig()` | `~/.config` | `~/.config` |
|
|
139
76
|
| `vscodeUserDir()` | `%APPDATA%\Code\User` | `~/Library/Application Support/Code/User` |
|
|
140
77
|
| `zedConfig()` | `%APPDATA%\Zed` | `~/.config/zed` |
|
|
141
|
-
| `HOME
|
|
78
|
+
| `HOME` 相对 | `~/.codex`、`~/.cursor` … | 相同 |
|
|
142
79
|
|
|
143
|
-
Zed
|
|
144
|
-
Application Support, so it gets its own resolver. `APPDATA`, `XDG_CONFIG_HOME`
|
|
145
|
-
and `COPILOT_HOME` are honoured when set.
|
|
146
|
-
Project-scope paths are always joined onto the workspace root, so they are
|
|
147
|
-
portable by construction. `scripts/test-paths.mjs` loads the real adapter source
|
|
148
|
-
under a simulated `win32`, `darwin` and `linux` and asserts the resolved
|
|
149
|
-
directory for every client.
|
|
80
|
+
Zed 是值得点名的例外:macOS 上它读 `~/.config/zed`,**不是** Application Support,所以它有独立解析器。`APPDATA`、`XDG_CONFIG_HOME`、`COPILOT_HOME` 被设置时会生效。
|
|
150
81
|
|
|
151
|
-
|
|
152
|
-
macOS the same rows resolve under `~/Library/Application Support/`. The scanner
|
|
153
|
-
reports the real absolute path it found, so the page always shows the truth for
|
|
154
|
-
the machine it is running on.
|
|
82
|
+
项目级路径始终拼在工作区根目录上,天然可移植。`scripts/test-paths.mjs` 在模拟的 `win32`、`darwin`、`linux` 下加载真实的适配器源码,逐个客户端断言解析出的目录。
|
|
155
83
|
|
|
156
|
-
##
|
|
84
|
+
## 架构
|
|
157
85
|
|
|
158
86
|
```
|
|
159
|
-
client.js (
|
|
87
|
+
client.js (设置页) index.js (宿主入口)
|
|
160
88
|
│ │
|
|
161
89
|
└──────── /api/dsh-mcp-manager ────┘
|
|
162
90
|
│ │
|
|
@@ -164,212 +92,109 @@ client.js (settings page) index.js (host entry)
|
|
|
164
92
|
│ │ │
|
|
165
93
|
adapters.js store.js surgical.js
|
|
166
94
|
│ │ │
|
|
167
|
-
canonical.js ledger+
|
|
168
|
-
(
|
|
95
|
+
canonical.js ledger+ 保字节编辑
|
|
96
|
+
(统一模型) 备份 JSON/TOML/YAML
|
|
169
97
|
│
|
|
170
98
|
dsh.js + dsh-patch.js + toggle.js
|
|
171
99
|
```
|
|
172
100
|
|
|
173
|
-
- **`canonical.js`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
- **`
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
checking the wrong field silently matches zero rows — which made every mounted
|
|
210
|
-
server look unreconciled and demand a restart that was never required.
|
|
211
|
-
|
|
212
|
-
## Safety model
|
|
213
|
-
|
|
214
|
-
The write path is built around one rule: **only rewrite what we can prove we
|
|
215
|
-
wrote.**
|
|
216
|
-
|
|
217
|
-
1. **Ownership ledger.** Every write records
|
|
218
|
-
`{path, serverName, fingerprint}`. A later write of the same name is applied
|
|
219
|
-
only if the definition on disk still matches the fingerprint — the user has
|
|
220
|
-
not edited it in the meantime.
|
|
221
|
-
2. **A foreign server is never clobbered.** A name already taken by an entry we
|
|
222
|
-
did not write is treated as the user's: the incoming server gets a suffixed
|
|
223
|
-
name instead.
|
|
224
|
-
3. **Optimistic concurrency.** The caller passes the hash it scanned; a changed
|
|
225
|
-
file returns `409` with the current hash, and the UI offers a re-scan. Silent
|
|
226
|
-
last-write-wins is not possible.
|
|
227
|
-
4. **Backup before every mutation**, retained per the `backupsToKeep` config,
|
|
228
|
-
restorable from the page.
|
|
229
|
-
5. **Atomic replace.** Write a temp file, `rename` over the target, then read
|
|
230
|
-
back and re-parse to prove the result is valid.
|
|
231
|
-
6. **Credentials are masked before they leave the host.** Masking is
|
|
232
|
-
key-driven, not value-driven — a short token (`TOKEN=abc`) is still masked,
|
|
233
|
-
while an indirection (`${FIGMA_API_KEY}`) is shown as-is because it names a
|
|
234
|
-
secret rather than carrying one. It covers every place a literal hides: env,
|
|
235
|
-
headers, unmodelled `extra`, the raw entry copy, and command-line flags
|
|
236
|
-
(`--figma-api-key=figd_…`, which is the documented invocation for several
|
|
237
|
-
servers and is not an `env` entry at all). `reveal` is an explicit,
|
|
238
|
-
confirmed action.
|
|
239
|
-
7. **Literal credentials are refused in project scope.** Writing a plaintext
|
|
240
|
-
key into a file destined for a repository fails with a hint to use an
|
|
241
|
-
environment reference.
|
|
242
|
-
8. **A masked definition can never be written back.** The browser only holds
|
|
243
|
-
the redacted copy, so every write resolves the definition from the host-side
|
|
244
|
-
scan; if a mask (`figd••••0000`) still reaches a write path it is refused
|
|
245
|
-
with `error.server.masked` instead of replacing a real credential with
|
|
246
|
-
bullets.
|
|
247
|
-
9. **Mutation requests need a custom header** (`x-dsh-mcp-manager: 1`) on top
|
|
248
|
-
of the JSON content-type and the loopback-origin check — a cross-site form
|
|
249
|
-
cannot set it.
|
|
250
|
-
|
|
251
|
-
## The switch mounts into DeepSeek Harness — and nothing else
|
|
252
|
-
|
|
253
|
-
The one control on each row is a switch, and it has exactly one meaning: **is
|
|
254
|
-
this server mounted in DeepSeek Harness?**
|
|
255
|
-
|
|
256
|
-
Turning it on mounts the server here. Turning it off unmounts it. The file the
|
|
257
|
-
definition came from is never opened for writing by the switch — verified by
|
|
258
|
-
hashing every client config before and after a mount/unmount cycle on this
|
|
259
|
-
machine, all unchanged.
|
|
260
|
-
|
|
261
|
-
That restriction is not squeamishness, it is what the formats force. Most clients
|
|
262
|
-
have no enable field at all: Qoder's and Trae's `mcp.json` carry no enable-ish key
|
|
263
|
-
whatsoever. Writing one would be a switch that lies.
|
|
264
|
-
|
|
265
|
-
How the mount works, by case:
|
|
266
|
-
|
|
267
|
-
| the server is | the switch does | takes effect |
|
|
101
|
+
- **`canonical.js` —— 唯一的语义模型。** 每个客户端条目都解码为 `{name, transport, command, args, env, url, headers, timeoutMs, enabled, autoApprove, extra, secrets}`。能力**不**向最小公约数对齐:某个客户端表达不了的字段,写入时丢弃并作为 `droppedFields` 上报,绝不悄悄编造。
|
|
102
|
+
- **`adapters.js` —— 唯一懂格式的层。** 十八个客户端即数据:容器键、方言(哪个键表示 url、哪种 type 字符串映射到哪种传输、超时单位)、能力、文件位置。
|
|
103
|
+
- **`surgical.js` —— 保字节编辑器。** 携带偏移量的解析树 + 自底向上的编辑,替换一条不会重排整个文件。注释、尾逗号、CRLF、无关的键都活下来;解析不了就拒绝,而不是重写。
|
|
104
|
+
- **`store.js` —— 安全网。** 写入前字节备份、比较并交换的哈希校验、归属台账。
|
|
105
|
+
- **`probe.js` —— 它到底能不能用。** 手写的 JSON-RPC 客户端(不依赖 `@modelcontextprotocol/sdk`):spawn stdio → `initialize` → `notifications/initialized` → `tools/list`;远程则 POST,带 `Accept: application/json, text/event-stream` 并跟随 SSE 响应。只读 —— 从不调用任何工具。
|
|
106
|
+
- **`dsh.js` / `dsh-patch.js` / `toggle.js` —— 把 Harness 自己也当成一等目标。** 已存在的行通过 `configEditor.edit()` 改写(校验 + 热重载)。全新的服务器没有行可寻址,于是写进组合文件的 `- insert:` 补丁层,Loader 会自己把它拾起来 —— 操作会等这一行出现并上报 `live: true`。
|
|
107
|
+
|
|
108
|
+
如果预算内没等到,它上报 `activating: true`,**绝不**说「需要重启」:补丁行只会写在 Loader 看得见的地方,所以它迟早会生效。给一个正在生效中的服务器提示重启是实测出来的错误 —— 连续挂载多个服务器会把重排推过任何固定预算,随后它们全部以 live 出现。
|
|
109
|
+
|
|
110
|
+
发现逻辑读的是 `entry.options.name`,不是 `entry.name`:运行中的 Loader 条目自身没有 `name` 属性(已在运行中的 Host 上验证过),读错字段会静默匹配到零行 —— 结果每个已挂载的服务器都看起来没生效,并索要一次根本不需要的重启。
|
|
111
|
+
|
|
112
|
+
## 安全模型
|
|
113
|
+
|
|
114
|
+
写入路径围绕一条规则构建:**只重写我们能证明是自己写的东西。**
|
|
115
|
+
|
|
116
|
+
1. **归属台账。** 每次写入记录 `{path, serverName, fingerprint}`。之后同名写入只有在磁盘上的定义仍与该指纹一致(用户没改过)时才生效。
|
|
117
|
+
2. **绝不覆盖别人的服务器。** 名字已被非我们写入的条目占用时,视为用户的:新来的服务器改用带后缀的名字。
|
|
118
|
+
3. **乐观并发。** 调用方传入它扫描到的哈希;文件变了就返回 `409` 和当前哈希,界面提示重新扫描。不存在静默的后写覆盖。
|
|
119
|
+
4. **每次修改前备份**,按 `backupsToKeep` 保留,可在页面还原。
|
|
120
|
+
5. **原子替换。** 写临时文件、`rename` 覆盖目标、再读回并重新解析,证明结果是合法的。
|
|
121
|
+
6. **凭据在离开宿主前就被掩码。** 掩码是按键驱动的,不是按值形状 —— 短 token(`TOKEN=abc`)照样掩码,而间接引用(`${FIGMA_API_KEY}`)原样显示,因为它指向密钥名而不是携带密钥。覆盖字面值所有可能藏身的地方:`env`、`headers`、未建模的 `extra`、原始条目副本,以及命令行参数(`--figma-api-key=figd_…`,这是好几个服务器的官方调用方式,根本不在 `env` 里)。`reveal` 是需要显式确认的动作。
|
|
122
|
+
7. **项目作用域拒绝字面凭据。** 往要进仓库的文件里写明文密钥会失败,并提示改用环境变量引用。
|
|
123
|
+
8. **被掩码的定义永远写不回去。** 浏览器手里只有脱敏副本,所以每次写入都从宿主侧的扫描结果解析定义;万一 `figd••••0000` 还是流到了写入路径,会以 `error.server.masked` 拒绝,而不是把真实凭据换成圆点。
|
|
124
|
+
9. **变更请求需要自定义头**(`x-dsh-mcp-manager: 1`),叠加在 JSON content-type 和 loopback 来源校验之上 —— 跨站表单设不了这个头。
|
|
125
|
+
|
|
126
|
+
## 开关只把服务器挂进 DeepSeek Harness,别的什么都不动
|
|
127
|
+
|
|
128
|
+
每行上的唯一控件是一个开关,含义只有一条:**这个服务器有没有挂进 DeepSeek Harness?**
|
|
129
|
+
|
|
130
|
+
打开就在 Harness 里挂载,关闭就卸载。定义来自哪个文件,开关**从不**以写入方式打开 —— 通过在本机对一次挂载/卸载循环前后哈希每个客户端配置来验证,全部未变。
|
|
131
|
+
|
|
132
|
+
这个限制不是洁癖,是格式逼出来的:大多数客户端根本没有 enable 字段。Qoder 和 Trae 的 `mcp.json` 里没有任何 enable 之类的键。写一个进去,就是一个会撒谎的开关。
|
|
133
|
+
|
|
134
|
+
按情形说明挂载怎么实现:
|
|
135
|
+
|
|
136
|
+
| 服务器是 | 开关做的事 | 生效时机 |
|
|
268
137
|
| --- | --- | --- |
|
|
269
|
-
|
|
|
270
|
-
|
|
|
271
|
-
|
|
|
272
|
-
|
|
|
273
|
-
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
`
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
the Loader id, so the switch finds its row under the mapped name;
|
|
305
|
-
- the server appears under the DSH file card with its display name, and the
|
|
306
|
-
original tools' cards are unchanged;
|
|
307
|
-
- two servers that sanitize to the same base still get distinct row names (the
|
|
308
|
-
hash differs), so no server silently replaces another.
|
|
309
|
-
|
|
310
|
-
`scripts/test-mount-name.mjs` pins the mapping, including the collision case and
|
|
311
|
-
the round trip through scan.
|
|
312
|
-
|
|
313
|
-
## Using it
|
|
314
|
-
|
|
315
|
-
The page is laid out like the skills page it sits next to: a title row with the
|
|
316
|
-
actions, a four-cell summary strip (servers · files · mounted in DSH · literal
|
|
317
|
-
credentials), a filter row, then one bordered card per config file.
|
|
318
|
-
|
|
319
|
-
Each card lists the servers that file declares — headed by the tool name, the
|
|
320
|
-
server count and the path (shortened to `%APPDATA%\…` style, full path in the
|
|
321
|
-
tooltip). A card collapses; while a filter is active every card opens, because a
|
|
322
|
-
match hidden inside a collapsed card reads as "no results". Every row shows the
|
|
323
|
-
transport, the command or URL, how many tools share the definition, its probe
|
|
324
|
-
result, and the mount switch.
|
|
325
|
-
|
|
326
|
-
The filters are a free-text search (name, command, path) plus two selects: by
|
|
327
|
-
tool, and by mount state (mounted / not mounted).
|
|
328
|
-
|
|
329
|
-
Clicking a row opens a dialog rather than expanding it in place — inline
|
|
330
|
-
expansion reflows every row below and made the card look like two stacked cards.
|
|
331
|
-
The dialog lists every file that declares the server, the canonical definition,
|
|
332
|
-
a paste-ready YAML row for a cordis composition, and "copy to…" for pushing the
|
|
333
|
-
definition into another tool.
|
|
334
|
-
|
|
335
|
-
The same operations are exposed to the agent as the `mcp_manage` tool, so the
|
|
336
|
-
page and the agent share one implementation and cannot diverge:
|
|
138
|
+
| 我们自己写的 `- insert:` 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
|
|
139
|
+
| 已经是运行中的 `dsh-mcp-client` 行 | `pluginManager.setPluginEnabled(rowId, …)` | 立即 —— 行卸载,其工具从工具列表消失 |
|
|
140
|
+
| 用户自己写的 `- insert:` 项 | 追加/移除插件管理器自己也会写的那种 `- id: …` / `disabled: true` 覆盖项 | 重排后生效 |
|
|
141
|
+
| 只声明在别的工具里,且被打开 | 写一条新的 `- insert:` 行 | Loader 重排后即生效(通常 1–4 秒);操作最多等 8 秒并上报 `live: true`,否则 `activating: true` |
|
|
142
|
+
| 哪里都没挂载,且被关闭 | 什么都不做 —— 本就是事实,不留悬空覆盖项 | — |
|
|
143
|
+
|
|
144
|
+
我们自己写的行会在尝试 Loader 路径之前被移除:插件管理器确实能热卸载一行,但它的做法是在定义旁边追加一条 `disabled: true` 覆盖项,那会让我们的行和这条覆盖项一起永久留在用户的组合文件里。删除 insert 项既是卸载也是清理。
|
|
145
|
+
|
|
146
|
+
因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 `cordis.patch.yml` 还原到精确的原始字节。
|
|
147
|
+
|
|
148
|
+
### 挂载名不总是显示名
|
|
149
|
+
|
|
150
|
+
`@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 这台机器上有十几个叫 `表单管理系统 - API 文档` 之类,`Figma AI Bridge` 带空格。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
|
|
151
|
+
|
|
152
|
+
因此 `canonical.js` 导出 `dshMountName(name)`:先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— `表单管理系统 - API 文档` → `API-c2f500c4`,`Figma AI Bridge` → `Figma-AI-Bridge`。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
|
|
153
|
+
|
|
154
|
+
扫描和页面处理的后果:
|
|
155
|
+
|
|
156
|
+
- 节点保留显示名;`mountedAs` 携带行名,`entryId` 携带 Loader id,开关按映射后的名字找到自己的行;
|
|
157
|
+
- 服务器出现在 DSH 那张文件卡片里时用显示名,其他工具的卡片不变;
|
|
158
|
+
- 两个 sanitize 后同名基的服务器仍得到不同的行名(哈希不同),不会有服务器悄悄替换另一个。
|
|
159
|
+
|
|
160
|
+
`scripts/test-mount-name.mjs` 钉住了这个映射,包括撞名情形和经由 scan 的往返。
|
|
161
|
+
|
|
162
|
+
## 怎么用
|
|
163
|
+
|
|
164
|
+
页面布局和它旁边的技能页一致:标题行加操作按钮,四格汇总条(服务器 · 文件 · 已挂载进 DSH · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
|
|
165
|
+
|
|
166
|
+
每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、有多少工具共用这份定义、探活结果,以及挂载开关。
|
|
167
|
+
|
|
168
|
+
筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按挂载状态(已挂载 / 未挂载)。
|
|
169
|
+
|
|
170
|
+
点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
|
|
171
|
+
|
|
172
|
+
同一批操作也以 `mcp_manage` 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔:
|
|
337
173
|
|
|
338
174
|
```
|
|
339
175
|
action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
|
|
340
176
|
```
|
|
341
177
|
|
|
342
|
-
##
|
|
178
|
+
## 配置
|
|
343
179
|
|
|
344
|
-
`cordis.patch.yml`
|
|
180
|
+
`cordis.patch.yml` 行配置:
|
|
345
181
|
|
|
346
|
-
|
|
|
182
|
+
| 键 | 默认 | 含义 |
|
|
347
183
|
| --- | --- | --- |
|
|
348
|
-
| `includeProjectScope` | `true` |
|
|
349
|
-
| `probeTimeoutMs` | `20000` |
|
|
350
|
-
| `probeConcurrency` | `4` |
|
|
351
|
-
| `backupsToKeep` | `20` |
|
|
184
|
+
| `includeProjectScope` | `true` | 同时扫描工作区级文件 |
|
|
185
|
+
| `probeTimeoutMs` | `20000` | 单个服务器探活预算 |
|
|
186
|
+
| `probeConcurrency` | `4` | 并行探活数 |
|
|
187
|
+
| `backupsToKeep` | `20` | 每个文件保留的备份数 |
|
|
352
188
|
|
|
353
|
-
|
|
189
|
+
状态(台账 + 备份)位于 `<DSH_HOME>/mcp-manager/`。
|
|
354
190
|
|
|
355
|
-
##
|
|
191
|
+
## 在本机验证过
|
|
356
192
|
|
|
357
|
-
|
|
358
|
-
OpenCode, Trae, Trae CN and DSH. Context7 probes OK with 2 tools,
|
|
359
|
-
`playwright_mcp` with 25. A DSH server mounted and unmounted through the page
|
|
360
|
-
leaves `cordis.patch.yml` **byte-identical** to its pre-write hash, with the
|
|
361
|
-
user's own provider, permission and agency entries untouched — and every other
|
|
362
|
-
client configuration file keeps its hash through the same cycle.
|
|
193
|
+
十八个客户端中装好了八个,跨 Qoder IDE、OpenCode、Trae、Trae CN 和 DSH 发现 27 个服务器。Context7 探活成功带 2 个工具,`playwright_mcp` 带 25 个。经页面挂载再卸载一个 DSH 服务器后,`cordis.patch.yml` 与写入前哈希**逐字节相同**,用户自己的 provider、permission、agency 条目原封不动 —— 同一循环中其他每个客户端配置文件也都保持哈希不变。
|
|
363
194
|
|
|
364
|
-
|
|
365
|
-
one in a command-line flag (`--figma-api-key` in Qoder's `mcp.json`). None of
|
|
366
|
-
their literals appears anywhere in the state payload; the page shows only key
|
|
367
|
-
names, and the write path refuses a masked value.
|
|
195
|
+
磁盘上发现 21 处明文凭据 —— 多数在 `env` 映射里,一处在命令行参数里(Qoder `mcp.json` 的 `--figma-api-key`)。它们的字面值没有出现在 state 载荷的任何位置;页面只显示键名,写入路径拒绝被掩码的值。
|
|
368
196
|
|
|
369
|
-
|
|
370
|
-
path, the DSH adapter, the patch layer, the mount switch, the mount-name mapping
|
|
371
|
-
for non-ASCII server names, redaction and the masked-write guard, plus an
|
|
372
|
-
end-to-end pass that asserts no credential literal reaches the client payload:
|
|
197
|
+
`scripts/` 下十三个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护、发布元数据一致性,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
|
|
373
198
|
|
|
374
199
|
```
|
|
375
200
|
node scripts/test-surgical.mjs 17 passing
|
|
@@ -384,25 +209,20 @@ node scripts/test-client-load.mjs 28 passing
|
|
|
384
209
|
node scripts/test-paths.mjs 16 passing
|
|
385
210
|
node scripts/test-mount-name.mjs 12 passing
|
|
386
211
|
node scripts/test-locale.mjs en/zh key parity
|
|
212
|
+
node scripts/test-release.mjs 11 passing
|
|
387
213
|
```
|
|
388
214
|
|
|
389
|
-
##
|
|
390
|
-
|
|
391
|
-
-
|
|
392
|
-
|
|
393
|
-
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
- **Secrets are not migrated to a vault.** The plugin masks and warns; moving
|
|
404
|
-
credentials into `${env:...}` references stays the user's decision.
|
|
405
|
-
- **The MCP registry is not consulted.** `registry.modelcontextprotocol.io` is
|
|
406
|
-
explicitly preview and not intended for direct host consumption, so server
|
|
407
|
-
discovery is by scanning local files only.
|
|
408
|
-
|
|
215
|
+
## 已知限制
|
|
216
|
+
|
|
217
|
+
- **安装检测靠文件存在性。** 装了但还没有 MCP 配置的工具,在目录出现之前与「没装」无法区分。
|
|
218
|
+
- **新挂载的 DSH 服务器依赖 Loader 重排。** 操作最多等 8 秒并上报 `live: true`;窗口过期则上报 `activating: true`,页面稍后重新读取状态。对 Loader 看得见的行,永不索要重启。
|
|
219
|
+
- **不是我们写的行只被关闭,不会被删除。** 删掉用户粘进去的定义等于毁掉他的工作,所以这样的行会得到与插件管理器相同的那种 `disabled: true` 覆盖项,并留在文件里。
|
|
220
|
+
- **只有 DSH 可切换。** 其他工具的文件只被显式读写,从不切换 —— 见上文。
|
|
221
|
+
- **Continue 只读**(YAML 数组形态);编辑在计划中。
|
|
222
|
+
- **凭据不会被迁移进保险库。** 插件只做掩码和告警;把凭据换成 `${env:...}` 引用仍是用户自己的决定。
|
|
223
|
+
- **不查询 MCP 注册表。** `registry.modelcontextprotocol.io` 明确是预览态且不面向宿主直接消费,所以服务器发现只靠扫描本地文件。
|
|
224
|
+
|
|
225
|
+
## 许可证
|
|
226
|
+
|
|
227
|
+
MIT
|
|
228
|
+
|
package/lib/probe.js
CHANGED
|
@@ -25,7 +25,7 @@ const PROTOCOL_VERSION = '2025-06-18';
|
|
|
25
25
|
* connection to this plugin and a version we still recognise. `test-release.mjs`
|
|
26
26
|
* asserts it matches package.json — a stale value here is otherwise invisible.
|
|
27
27
|
*/
|
|
28
|
-
const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.
|
|
28
|
+
const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.2' };
|
|
29
29
|
|
|
30
30
|
function rpcRequest(id, method, params) {
|
|
31
31
|
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-agent/dsh-mcp-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "One settings page for every MCP server on your machine: scan each AI tool's own config file, see which directory holds which server, probe whether it really starts, and mount or unmount it in DeepSeek Harness without ever editing the other tools' files. / 一个设置页管理本机所有 AI 开发工具的 MCP:按配置文件分组展示、真连探活、只在 DeepSeek Harness 内挂载/卸载,绝不改动其他工具的配置。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"cordis.patch.yml",
|
|
20
20
|
"icon.svg",
|
|
21
21
|
"README.md",
|
|
22
|
-
"README.
|
|
22
|
+
"README.en.md",
|
|
23
23
|
"LICENSE"
|
|
24
24
|
],
|
|
25
25
|
"dsh": {
|
package/README.zh.md
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
# @oh-agent/dsh-mcp-manager
|
|
2
|
-
|
|
3
|
-
一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来。
|
|
4
|
-
|
|
5
|
-
入口:DeepSeek Harness → **设置 → MCP**(位于「技能管理」和「插件市场」之间)。
|
|
6
|
-
|
|
7
|
-
## 安装
|
|
8
|
-
|
|
9
|
-
**方式一:插件管理页**(推荐)
|
|
10
|
-
|
|
11
|
-
设置 → 插件管理 → 安装,搜索 `@oh-agent/dsh-mcp-manager`。
|
|
12
|
-
|
|
13
|
-
**方式二:让 agent 安装**
|
|
14
|
-
|
|
15
|
-
直接说「安装 @oh-agent/dsh-mcp-manager」,等价于:
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.1"
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
`target` 也可以填一个绝对目录 —— 本地源码目录,或者
|
|
22
|
-
[v0.1.1 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
|
|
23
|
-
`dsh-mcp-manager.tgz` 解出来的目录。
|
|
24
|
-
|
|
25
|
-
**不要**手动 `pnpm add` 进 profile,也不要手改 profile 的 `package.json` 或
|
|
26
|
-
`cordis.patch.yml`:装了包不等于选中了它的 bundle,手动装的后果是那一行存在但永远不会被挂载。
|
|
27
|
-
|
|
28
|
-
安装后增加一个设置页和一个 agent 工具(`mcp_manage`)。它没有任何运行时依赖,不需要构建,也没有 `allowBuilds` 授权步骤。要求 Node `^22.19.0 || >=24.0.0`(DeepSeek Harness 自带 Node 24,无需另装)。
|
|
29
|
-
|
|
30
|
-
页面会读取其他工具的配置文件,但**开关不需要对它们有写权限**。所有数据都不离开本机 —— 唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
|
|
31
|
-
|
|
32
|
-
## 它解决什么问题
|
|
33
|
-
|
|
34
|
-
同一个 MCP 服务器,在每个工具里声明在不同的文件、不同的键、不同的方言下:
|
|
35
|
-
|
|
36
|
-
| 工具 | 文件(Windows) | 顶层键 |
|
|
37
|
-
| --- | --- | --- |
|
|
38
|
-
| Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` |
|
|
39
|
-
| Claude Code | `~/.claude.json`、`<proj>/.mcp.json` | `mcpServers` |
|
|
40
|
-
| Cursor | `~/.cursor/mcp.json` | `mcpServers` |
|
|
41
|
-
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
|
|
42
|
-
| VS Code | `<ws>/.vscode/mcp.json` | `servers` |
|
|
43
|
-
| Roo Code | `…\rooveterinaryinc.roo-cline\settings\cline_mcp_settings.json` | `mcpServers` |
|
|
44
|
-
| Gemini CLI | `~/.gemini/settings.json` | `mcpServers` |
|
|
45
|
-
| Qwen Code | `~/.qwen/settings.json` | `mcpServers` |
|
|
46
|
-
| Qoder IDE | `%APPDATA%\Qoder\SharedClientCache\mcp.json` | `mcpServers` |
|
|
47
|
-
| OpenCode | `~/.config/opencode/opencode.json` | `mcp` |
|
|
48
|
-
| Trae | `%APPDATA%\Trae\User\mcp.json` | `mcpServers` |
|
|
49
|
-
| Trae CN | `%APPDATA%\Trae CN\User\mcp.json` | `mcpServers` |
|
|
50
|
-
| Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` |
|
|
51
|
-
| Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` |
|
|
52
|
-
| Zed | `settings.json` | `context_servers` |
|
|
53
|
-
| Continue | `~/.continue/config.yaml` | `mcpServers`(YAML 数组) |
|
|
54
|
-
| CodeBuddy | `%APPDATA%\CodeBuddy\...` | `mcpServers` |
|
|
55
|
-
| **DeepSeek Harness** | profile 组合文件 | `@deepseek-ai/dsh-mcp-client` 行 |
|
|
56
|
-
|
|
57
|
-
这些差异不是美观问题,而是手改最容易翻车的地方:
|
|
58
|
-
|
|
59
|
-
- **远程传输的拼写不同。** Windsurf 只认 `serverUrl`,写成 `url` 会被静默忽略。Gemini 和 Qwen 区分 `url`(SSE)与 `httpUrl`(streamable HTTP),而 Roo 把 streamable HTTP 拼成 `streamable-http`。
|
|
60
|
-
- **超时单位不同。** Gemini / Qwen / DSH 是毫秒,Roo 是秒。
|
|
61
|
-
- **命令的形态不同。** OpenCode 的 `command` 是数组,`env` 叫 `environment`;其他工具是字符串 `command` 加独立的 `args`。
|
|
62
|
-
- **有些文件混着无关状态。** `~/.claude.json` 里还有历史记录和 OAuth token;`opencode.json` 里有 provider 和 plugin。整文件重写会把这些一起毁掉。
|
|
63
|
-
|
|
64
|
-
本插件把这一切读入同一个模型,写回时只改它想改的那一条。
|
|
65
|
-
|
|
66
|
-
### Windows 与 macOS
|
|
67
|
-
|
|
68
|
-
没有任何路径写死在单一平台上。每个适配器通过读取操作系统的解析器声明自己的文件:
|
|
69
|
-
|
|
70
|
-
| 根 | Windows | macOS |
|
|
71
|
-
| --- | --- | --- |
|
|
72
|
-
| `appData()` | `%APPDATA%` | `~/Library/Application Support` |
|
|
73
|
-
| `xdgConfig()` | `~/.config` | `~/.config` |
|
|
74
|
-
| `vscodeUserDir()` | `%APPDATA%\Code\User` | `~/Library/Application Support/Code/User` |
|
|
75
|
-
| `zedConfig()` | `%APPDATA%\Zed` | `~/.config/zed` |
|
|
76
|
-
| `HOME` 相对 | `~/.codex`、`~/.cursor` … | 相同 |
|
|
77
|
-
|
|
78
|
-
Zed 是值得点名的例外:macOS 上它读 `~/.config/zed`,**不是** Application Support,所以它有独立解析器。`APPDATA`、`XDG_CONFIG_HOME`、`COPILOT_HOME` 被设置时会生效。
|
|
79
|
-
|
|
80
|
-
项目级路径始终拼在工作区根目录上,天然可移植。`scripts/test-paths.mjs` 在模拟的 `win32`、`darwin`、`linux` 下加载真实的适配器源码,逐个客户端断言解析出的目录。
|
|
81
|
-
|
|
82
|
-
## 架构
|
|
83
|
-
|
|
84
|
-
```
|
|
85
|
-
client.js (设置页) index.js (宿主入口)
|
|
86
|
-
│ │
|
|
87
|
-
└──────── /api/dsh-mcp-manager ────┘
|
|
88
|
-
│ │
|
|
89
|
-
scan.js ops.js probe.js
|
|
90
|
-
│ │ │
|
|
91
|
-
adapters.js store.js surgical.js
|
|
92
|
-
│ │ │
|
|
93
|
-
canonical.js ledger+ 保字节编辑
|
|
94
|
-
(统一模型) 备份 JSON/TOML/YAML
|
|
95
|
-
│
|
|
96
|
-
dsh.js + dsh-patch.js + toggle.js
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- **`canonical.js` —— 唯一的语义模型。** 每个客户端条目都解码为 `{name, transport, command, args, env, url, headers, timeoutMs, enabled, autoApprove, extra, secrets}`。能力**不**向最小公约数对齐:某个客户端表达不了的字段,写入时丢弃并作为 `droppedFields` 上报,绝不悄悄编造。
|
|
100
|
-
- **`adapters.js` —— 唯一懂格式的层。** 十八个客户端即数据:容器键、方言(哪个键表示 url、哪种 type 字符串映射到哪种传输、超时单位)、能力、文件位置。
|
|
101
|
-
- **`surgical.js` —— 保字节编辑器。** 携带偏移量的解析树 + 自底向上的编辑,替换一条不会重排整个文件。注释、尾逗号、CRLF、无关的键都活下来;解析不了就拒绝,而不是重写。
|
|
102
|
-
- **`store.js` —— 安全网。** 写入前字节备份、比较并交换的哈希校验、归属台账。
|
|
103
|
-
- **`probe.js` —— 它到底能不能用。** 手写的 JSON-RPC 客户端(不依赖 `@modelcontextprotocol/sdk`):spawn stdio → `initialize` → `notifications/initialized` → `tools/list`;远程则 POST,带 `Accept: application/json, text/event-stream` 并跟随 SSE 响应。只读 —— 从不调用任何工具。
|
|
104
|
-
- **`dsh.js` / `dsh-patch.js` / `toggle.js` —— 把 Harness 自己也当成一等目标。** 已存在的行通过 `configEditor.edit()` 改写(校验 + 热重载)。全新的服务器没有行可寻址,于是写进组合文件的 `- insert:` 补丁层,Loader 会自己把它拾起来 —— 操作会等这一行出现并上报 `live: true`。
|
|
105
|
-
|
|
106
|
-
如果预算内没等到,它上报 `activating: true`,**绝不**说「需要重启」:补丁行只会写在 Loader 看得见的地方,所以它迟早会生效。给一个正在生效中的服务器提示重启是实测出来的错误 —— 连续挂载多个服务器会把重排推过任何固定预算,随后它们全部以 live 出现。
|
|
107
|
-
|
|
108
|
-
发现逻辑读的是 `entry.options.name`,不是 `entry.name`:运行中的 Loader 条目自身没有 `name` 属性(已在运行中的 Host 上验证过),读错字段会静默匹配到零行 —— 结果每个已挂载的服务器都看起来没生效,并索要一次根本不需要的重启。
|
|
109
|
-
|
|
110
|
-
## 安全模型
|
|
111
|
-
|
|
112
|
-
写入路径围绕一条规则构建:**只重写我们能证明是自己写的东西。**
|
|
113
|
-
|
|
114
|
-
1. **归属台账。** 每次写入记录 `{path, serverName, fingerprint}`。之后同名写入只有在磁盘上的定义仍与该指纹一致(用户没改过)时才生效。
|
|
115
|
-
2. **绝不覆盖别人的服务器。** 名字已被非我们写入的条目占用时,视为用户的:新来的服务器改用带后缀的名字。
|
|
116
|
-
3. **乐观并发。** 调用方传入它扫描到的哈希;文件变了就返回 `409` 和当前哈希,界面提示重新扫描。不存在静默的后写覆盖。
|
|
117
|
-
4. **每次修改前备份**,按 `backupsToKeep` 保留,可在页面还原。
|
|
118
|
-
5. **原子替换。** 写临时文件、`rename` 覆盖目标、再读回并重新解析,证明结果是合法的。
|
|
119
|
-
6. **凭据在离开宿主前就被掩码。** 掩码是按键驱动的,不是按值形状 —— 短 token(`TOKEN=abc`)照样掩码,而间接引用(`${FIGMA_API_KEY}`)原样显示,因为它指向密钥名而不是携带密钥。覆盖字面值所有可能藏身的地方:`env`、`headers`、未建模的 `extra`、原始条目副本,以及命令行参数(`--figma-api-key=figd_…`,这是好几个服务器的官方调用方式,根本不在 `env` 里)。`reveal` 是需要显式确认的动作。
|
|
120
|
-
7. **项目作用域拒绝字面凭据。** 往要进仓库的文件里写明文密钥会失败,并提示改用环境变量引用。
|
|
121
|
-
8. **被掩码的定义永远写不回去。** 浏览器手里只有脱敏副本,所以每次写入都从宿主侧的扫描结果解析定义;万一 `figd••••0000` 还是流到了写入路径,会以 `error.server.masked` 拒绝,而不是把真实凭据换成圆点。
|
|
122
|
-
9. **变更请求需要自定义头**(`x-dsh-mcp-manager: 1`),叠加在 JSON content-type 和 loopback 来源校验之上 —— 跨站表单设不了这个头。
|
|
123
|
-
|
|
124
|
-
## 开关只把服务器挂进 DeepSeek Harness,别的什么都不动
|
|
125
|
-
|
|
126
|
-
每行上的唯一控件是一个开关,含义只有一条:**这个服务器有没有挂进 DeepSeek Harness?**
|
|
127
|
-
|
|
128
|
-
打开就在 Harness 里挂载,关闭就卸载。定义来自哪个文件,开关**从不**以写入方式打开 —— 通过在本机对一次挂载/卸载循环前后哈希每个客户端配置来验证,全部未变。
|
|
129
|
-
|
|
130
|
-
这个限制不是洁癖,是格式逼出来的:大多数客户端根本没有 enable 字段。Qoder 和 Trae 的 `mcp.json` 里没有任何 enable 之类的键。写一个进去,就是一个会撒谎的开关。
|
|
131
|
-
|
|
132
|
-
按情形说明挂载怎么实现:
|
|
133
|
-
|
|
134
|
-
| 服务器是 | 开关做的事 | 生效时机 |
|
|
135
|
-
| --- | --- | --- |
|
|
136
|
-
| 我们自己写的 `- insert:` 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
|
|
137
|
-
| 已经是运行中的 `dsh-mcp-client` 行 | `pluginManager.setPluginEnabled(rowId, …)` | 立即 —— 行卸载,其工具从工具列表消失 |
|
|
138
|
-
| 用户自己写的 `- insert:` 项 | 追加/移除插件管理器自己也会写的那种 `- id: …` / `disabled: true` 覆盖项 | 重排后生效 |
|
|
139
|
-
| 只声明在别的工具里,且被打开 | 写一条新的 `- insert:` 行 | Loader 重排后即生效(通常 1–4 秒);操作最多等 8 秒并上报 `live: true`,否则 `activating: true` |
|
|
140
|
-
| 哪里都没挂载,且被关闭 | 什么都不做 —— 本就是事实,不留悬空覆盖项 | — |
|
|
141
|
-
|
|
142
|
-
我们自己写的行会在尝试 Loader 路径之前被移除:插件管理器确实能热卸载一行,但它的做法是在定义旁边追加一条 `disabled: true` 覆盖项,那会让我们的行和这条覆盖项一起永久留在用户的组合文件里。删除 insert 项既是卸载也是清理。
|
|
143
|
-
|
|
144
|
-
因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 `cordis.patch.yml` 还原到精确的原始字节。
|
|
145
|
-
|
|
146
|
-
### 挂载名不总是显示名
|
|
147
|
-
|
|
148
|
-
`@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 这台机器上有十几个叫 `表单管理系统 - API 文档` 之类,`Figma AI Bridge` 带空格。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
|
|
149
|
-
|
|
150
|
-
因此 `canonical.js` 导出 `dshMountName(name)`:先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— `表单管理系统 - API 文档` → `API-c2f500c4`,`Figma AI Bridge` → `Figma-AI-Bridge`。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
|
|
151
|
-
|
|
152
|
-
扫描和页面处理的后果:
|
|
153
|
-
|
|
154
|
-
- 节点保留显示名;`mountedAs` 携带行名,`entryId` 携带 Loader id,开关按映射后的名字找到自己的行;
|
|
155
|
-
- 服务器出现在 DSH 那张文件卡片里时用显示名,其他工具的卡片不变;
|
|
156
|
-
- 两个 sanitize 后同名基的服务器仍得到不同的行名(哈希不同),不会有服务器悄悄替换另一个。
|
|
157
|
-
|
|
158
|
-
`scripts/test-mount-name.mjs` 钉住了这个映射,包括撞名情形和经由 scan 的往返。
|
|
159
|
-
|
|
160
|
-
## 怎么用
|
|
161
|
-
|
|
162
|
-
页面布局和它旁边的技能页一致:标题行加操作按钮,四格汇总条(服务器 · 文件 · 已挂载进 DSH · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
|
|
163
|
-
|
|
164
|
-
每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、有多少工具共用这份定义、探活结果,以及挂载开关。
|
|
165
|
-
|
|
166
|
-
筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按挂载状态(已挂载 / 未挂载)。
|
|
167
|
-
|
|
168
|
-
点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
|
|
169
|
-
|
|
170
|
-
同一批操作也以 `mcp_manage` 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔:
|
|
171
|
-
|
|
172
|
-
```
|
|
173
|
-
action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## 配置
|
|
177
|
-
|
|
178
|
-
`cordis.patch.yml` 行配置:
|
|
179
|
-
|
|
180
|
-
| 键 | 默认 | 含义 |
|
|
181
|
-
| --- | --- | --- |
|
|
182
|
-
| `includeProjectScope` | `true` | 同时扫描工作区级文件 |
|
|
183
|
-
| `probeTimeoutMs` | `20000` | 单个服务器探活预算 |
|
|
184
|
-
| `probeConcurrency` | `4` | 并行探活数 |
|
|
185
|
-
| `backupsToKeep` | `20` | 每个文件保留的备份数 |
|
|
186
|
-
|
|
187
|
-
状态(台账 + 备份)位于 `<DSH_HOME>/mcp-manager/`。
|
|
188
|
-
|
|
189
|
-
## 在本机验证过
|
|
190
|
-
|
|
191
|
-
十八个客户端中装好了八个,跨 Qoder IDE、OpenCode、Trae、Trae CN 和 DSH 发现 27 个服务器。Context7 探活成功带 2 个工具,`playwright_mcp` 带 25 个。经页面挂载再卸载一个 DSH 服务器后,`cordis.patch.yml` 与写入前哈希**逐字节相同**,用户自己的 provider、permission、agency 条目原封不动 —— 同一循环中其他每个客户端配置文件也都保持哈希不变。
|
|
192
|
-
|
|
193
|
-
磁盘上发现 21 处明文凭据 —— 多数在 `env` 映射里,一处在命令行参数里(Qoder `mcp.json` 的 `--figma-api-key`)。它们的字面值没有出现在 state 载荷的任何位置;页面只显示键名,写入路径拒绝被掩码的值。
|
|
194
|
-
|
|
195
|
-
`scripts/` 下十二个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
|
|
196
|
-
|
|
197
|
-
```
|
|
198
|
-
node scripts/test-surgical.mjs 17 passing
|
|
199
|
-
node scripts/test-write.mjs 12 passing
|
|
200
|
-
node scripts/test-dsh.mjs 16 passing
|
|
201
|
-
node scripts/test-dsh-patch.mjs 15 passing
|
|
202
|
-
node scripts/test-dsh-e2e.mjs 8 passing
|
|
203
|
-
node scripts/test-toggle.mjs 16 passing
|
|
204
|
-
node scripts/test-redact.mjs 14 passing
|
|
205
|
-
node scripts/test-masked-write.mjs 5 passing
|
|
206
|
-
node scripts/test-client-load.mjs 28 passing
|
|
207
|
-
node scripts/test-paths.mjs 16 passing
|
|
208
|
-
node scripts/test-mount-name.mjs 12 passing
|
|
209
|
-
node scripts/test-locale.mjs en/zh key parity
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
## 已知限制
|
|
213
|
-
|
|
214
|
-
- **安装检测靠文件存在性。** 装了但还没有 MCP 配置的工具,在目录出现之前与「没装」无法区分。
|
|
215
|
-
- **新挂载的 DSH 服务器依赖 Loader 重排。** 操作最多等 8 秒并上报 `live: true`;窗口过期则上报 `activating: true`,页面稍后重新读取状态。对 Loader 看得见的行,永不索要重启。
|
|
216
|
-
- **不是我们写的行只被关闭,不会被删除。** 删掉用户粘进去的定义等于毁掉他的工作,所以这样的行会得到与插件管理器相同的那种 `disabled: true` 覆盖项,并留在文件里。
|
|
217
|
-
- **只有 DSH 可切换。** 其他工具的文件只被显式读写,从不切换 —— 见上文。
|
|
218
|
-
- **Continue 只读**(YAML 数组形态);编辑在计划中。
|
|
219
|
-
- **凭据不会被迁移进保险库。** 插件只做掩码和告警;把凭据换成 `${env:...}` 引用仍是用户自己的决定。
|
|
220
|
-
- **不查询 MCP 注册表。** `registry.modelcontextprotocol.io` 明确是预览态且不面向宿主直接消费,所以服务器发现只靠扫描本地文件。
|
|
221
|
-
|
|
222
|
-
## 许可证
|
|
223
|
-
|
|
224
|
-
MIT
|
|
225
|
-
|