@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.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/lib/toggle.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Mount / unmount one MCP server in DeepSeek Harness.
3
+ *
4
+ * The switch is deliberately NOT a `enabled: false` field written into a client
5
+ * config file. Two reasons, both load-bearing:
6
+ *
7
+ * 1. Most clients have no such field. Qoder's mcp.json and Trae's mcp.json
8
+ * carry no enable-ish key at all. Writing one would mean writing a key the
9
+ * tool ignores — a switch that lies.
10
+ * 2. The user was explicit that the switch must not disturb the original
11
+ * directories or their tools. So the only file this ever edits is OUR OWN
12
+ * profile composition.
13
+ *
14
+ * What a switch actually does, by case:
15
+ *
16
+ * live row — `pluginManager.setPluginEnabled(rowId, enabled)`. The Loader
17
+ * disposes the mcp-client instance, so its tools leave the tool
18
+ * list, and re-enabling re-inits it. Hot, no restart.
19
+ * patch row — the server exists only as an `- insert:` item that has not
20
+ * mounted yet (or mounted in a composition we cannot address).
21
+ * We append/remove the same `- id: <patchId> / disabled: true`
22
+ * override the plugin manager writes, so the definition stays
23
+ * byte-identical and re-enabling restores the file exactly.
24
+ * other file — refused. Toggling there would edit the other tool's config.
25
+ */
26
+ import { setDshPatchDisabled, removeDshPatchServer, readDshPatchServers } from './dsh-patch.js';
27
+ import { dshMountName, sanitizeName } from './canonical.js';
28
+
29
+ function pluginManagerOf(get) {
30
+ if (typeof get !== 'function') return undefined;
31
+ try {
32
+ return get('pluginManager');
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /** Did *this plugin* write the row for this server, rather than the user? */
39
+ function ownedByUs(store, name) {
40
+ if (!store || typeof store.ownershipOf !== 'function') return false;
41
+ try {
42
+ const bucket = store.ownershipOf(name);
43
+ return Boolean(bucket && bucket.targets && bucket.targets['dsh:profile:(profile composition)']);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @returns {Promise<{ok:boolean, changed?:boolean, enabled?:boolean,
51
+ * via?:'loader'|'patch', restartRequired?:boolean, target?:string,
52
+ * code?:string, error?:string, hint?:string}>}
53
+ */
54
+ export async function toggleServer({ get, profileDir, store }, { name, enabled, entryId, mountName }) {
55
+ if (!name) return { ok: false, code: 'error.input', error: 'name is required' };
56
+ if (typeof enabled !== 'boolean') return { ok: false, code: 'error.input', error: 'enabled must be a boolean' };
57
+
58
+ /*
59
+ * The row in the composition is keyed by the MOUNT name, not the display
60
+ * name, and the two differ whenever the harness cannot accept the display
61
+ * name verbatim (`serverName` is `[A-Za-z0-9_-]{1,32}`). Addressing the row by
62
+ * the display name found nothing, so the switch fell through to the patch
63
+ * layer and demanded a restart for a server it had just mounted. Accept the
64
+ * scan's `mountName` when given and otherwise derive the same mapping.
65
+ */
66
+ const rowName = mountName ? sanitizeName(mountName) : dshMountName(name);
67
+
68
+ const manager = pluginManagerOf(get);
69
+
70
+ /*
71
+ * Unmounting a row WE created takes our own row out again.
72
+ *
73
+ * This runs first, before the Loader path and before the manager check, for
74
+ * three reasons: the Loader disposes the instance hot but does so by appending
75
+ * a `disabled: true` override beside the `- insert:` item, which leaves our
76
+ * row (and the override) in the user's composition forever; removing the
77
+ * insert item returns the file to exactly the bytes it had before we mounted
78
+ * anything; and removing a row needs no service, so the switch still works if
79
+ * the plugin manager is unavailable. A row the user authored is never touched
80
+ * here — `ownedByUs` requires our own ledger entry for it.
81
+ */
82
+ if (!enabled && ownedByUs(store, rowName)) {
83
+ const removed = removeDshPatchServer({ serverName: rowName, store, profileDir });
84
+ if (removed.ok && removed.changed) {
85
+ return { ok: true, changed: true, enabled: false, via: 'patch', restartRequired: false, target: removed.path, unmounted: true };
86
+ }
87
+ }
88
+
89
+ if (!manager) {
90
+ /*
91
+ * No plugin manager: a row we can reach in the patch layer can still be
92
+ * switched with an override item, but a live Loader row cannot be addressed
93
+ * at all. Try the patch layer, and only refuse if there is nothing there.
94
+ */
95
+ const pending = readDshPatchServers(profileDir).find((item) => item.name === rowName);
96
+ if (pending) {
97
+ const patched = setDshPatchDisabled({ serverName: rowName, disabled: !enabled, store, profileDir });
98
+ if (patched.ok) {
99
+ return { ok: true, changed: patched.changed === true, enabled, via: 'patch', restartRequired: patched.changed === true, target: patched.path };
100
+ }
101
+ }
102
+ return {
103
+ ok: false,
104
+ code: 'error.toggle.noManager',
105
+ error: 'the plugin manager service is not available, so a live row cannot be mounted or unmounted',
106
+ hint: 'this switch only mounts servers in DeepSeek Harness; without the plugin manager there is nothing to switch',
107
+ };
108
+ }
109
+
110
+ /*
111
+ * Address the row by entryId when the scan supplied one. Falling back to the
112
+ * `mcp-<serverName>` convention covers rows created from our patch layer,
113
+ * whose Loader id we chose ourselves; a row the user authored is addressed by
114
+ * the id the scan read off the tree, never guessed.
115
+ *
116
+ * The `include:` spelling is not decoration: `configEditor.configuration()`
117
+ * lists only rows that were already in the composition, so a server we just
118
+ * inserted into cordis.patch.yml has NO entryId in the scan even after the
119
+ * Loader reconciled it — yet the Loader addresses it as `include:mcp-<name>`
120
+ * (that is the id the bundle patch writes). Without this candidate every
121
+ * unmount of a row we created would fall through to the patch layer, which
122
+ * needs a restart, when the plugin manager could have disposed the instance
123
+ * hot. Tried after the real entryId and before the bare patch id, so an id
124
+ * read off the tree always wins.
125
+ */
126
+ const candidates = [entryId, `include:mcp-${rowName}`, `mcp-${rowName}`]
127
+ .filter((value, index, list) => value && list.indexOf(value) === index);
128
+
129
+ let lastError;
130
+ for (const id of candidates) {
131
+ try {
132
+ const result = await manager.setPluginEnabled(id, enabled);
133
+ if (result && result.application === 'failed') {
134
+ lastError = result.error ?? { code: 'error.toggle.failed', error: `plugin manager failed to ${enabled ? 'enable' : 'disable'} ${id}` };
135
+ continue;
136
+ }
137
+ return {
138
+ ok: true,
139
+ changed: result?.changed === true,
140
+ enabled,
141
+ via: 'loader',
142
+ restartRequired: result?.application === 'restart-required',
143
+ target: id,
144
+ overridden: result?.application === 'overridden',
145
+ };
146
+ } catch (error) {
147
+ lastError = { code: 'error.toggle.failed', error: String(error && error.message ? error.message : error) };
148
+ }
149
+ }
150
+
151
+ /*
152
+ * No live row answered. Either the server is only an insert item we have not
153
+ * reconciled yet, or it is not mounted here at all.
154
+ */
155
+ const pending = readDshPatchServers(profileDir).find((item) => item.name === rowName);
156
+
157
+ if (!pending) {
158
+ /*
159
+ * Not mounted and not pending. Turning the switch off is then already true;
160
+ * turning it on has nothing to mount. Say so instead of writing a
161
+ * `disabled: true` override for a row that does not exist — that override
162
+ * would dangle in the user's composition forever.
163
+ */
164
+ if (!enabled) {
165
+ return { ok: true, changed: false, enabled: false, notMounted: true, via: 'none', restartRequired: false, target: null };
166
+ }
167
+ return {
168
+ ok: false,
169
+ code: 'error.toggle.notMounted',
170
+ error: lastError?.error ?? `no mounted row and no patch row for ${name}`,
171
+ hint: 'this server is defined in another tool only; its switch appears once it is mounted in DeepSeek Harness',
172
+ };
173
+ }
174
+
175
+ /*
176
+ * A row we did not create stays in place: it is someone's authored definition,
177
+ * so the switch flips it with the same `disabled: true` override item the
178
+ * plugin manager itself would write, keeping the definition byte-identical.
179
+ * (Our own rows were already removed above.)
180
+ */
181
+ const patched = setDshPatchDisabled({ serverName: rowName, disabled: !enabled, store, profileDir });
182
+ if (patched.ok) {
183
+ return { ok: true, changed: patched.changed === true, enabled, via: 'patch', restartRequired: patched.changed === true, target: patched.path };
184
+ }
185
+
186
+ return {
187
+ ok: false,
188
+ // A stable code the UI can key on; the manager's own reason rides along in
189
+ // `error` so a real failure is never hidden behind the generic one.
190
+ code: 'error.toggle.notMounted',
191
+ error: lastError?.error ?? `no mounted row and no patch row for ${name}`,
192
+ hint: 'this server is defined in another tool only; its switch appears once it is mounted in DeepSeek Harness',
193
+ };
194
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "MCP Manager",
4
+ "description": "Detect, compare, and manage MCP servers across local AI development tools and DeepSeek Harness."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "MCP 管理",
4
+ "description": "统一检测、比对与管理本机各 AI 开发工具和 DeepSeek Harness 的 MCP 服务器配置。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@oh-agent/dsh-mcp-manager",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json",
12
+ "./cordis.patch.yml": "./cordis.patch.yml",
13
+ "./locale/*.json": "./locale/*.json",
14
+ "./icon": "./icon.svg"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "locale/*.json",
19
+ "cordis.patch.yml",
20
+ "icon.svg",
21
+ "README.md",
22
+ "README.zh.md",
23
+ "LICENSE"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "immediately": true,
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-ui-slots",
34
+ "@deepseek-ai/dsh-client-locale"
35
+ ]
36
+ }
37
+ },
38
+ "keywords": [
39
+ "dsh",
40
+ "deepseek-harness",
41
+ "plugin",
42
+ "bundle",
43
+ "mcp",
44
+ "model-context-protocol",
45
+ "mcp-server",
46
+ "mcp-manager",
47
+ "claude-code",
48
+ "cursor",
49
+ "vscode",
50
+ "opencode",
51
+ "trae",
52
+ "qoder",
53
+ "gemini-cli",
54
+ "zed",
55
+ "config"
56
+ ],
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/wangbobo-coder/dsh-mcp-manager.git"
60
+ },
61
+ "homepage": "https://github.com/wangbobo-coder/dsh-mcp-manager#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/wangbobo-coder/dsh-mcp-manager/issues"
64
+ },
65
+ "engines": {
66
+ "node": "^22.19.0 || >=24.0.0"
67
+ }
68
+ }
69
+