@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Adding MCP rows to DeepSeek Harness, not just editing existing ones.
3
+ *
4
+ * `configEditor.edit()` can only rewrite a row that already exists; a brand-new
5
+ * MCP server has no entry to address. The composition's patch layer is the
6
+ * supported insertion point (`- insert:` is a documented top-level patch
7
+ * entry), so new servers are appended there and existing ones are rewritten
8
+ * through configEditor.
9
+ *
10
+ * These helpers only touch `- insert:` items that this plugin created (matched
11
+ * by the `mcp-<serverName>` id plus the mcp-client package name), so a user's
12
+ * own patch entries — provider configs, agency agents, anything hand-written —
13
+ * are never rewritten. Every write is backed up by the store first.
14
+ */
15
+
16
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
18
+ import { MCP_CLIENT_PACKAGE, encodeDshEntry } from './dsh.js';
19
+
20
+ const INSERT_ID_PREFIX = 'mcp-';
21
+
22
+ /** Where the active profile keeps its patch layer. */
23
+ export function profilePatchPath(profileDir) {
24
+ const base = profileDir ?? process.env.DSH_PROFILE_DIR ?? (process.env.DSH_HOME ? join(process.env.DSH_HOME, 'profiles', 'desktop') : undefined);
25
+ if (!base) return undefined;
26
+ return join(base, 'cordis.patch.yml');
27
+ }
28
+
29
+ /**
30
+ * Split a patch file into its preamble (comments before the first entry) and
31
+ * its top-level `- ` items, keeping each item's source text verbatim.
32
+ *
33
+ * A byte-preserving split is what makes targeted removal safe: rebuilding the
34
+ * file from items we did not author would reformat the user's own entries.
35
+ */
36
+ export function splitPatchItems(text) {
37
+ const lines = text.split('\n');
38
+ const preamble = [];
39
+ const items = [];
40
+ let current = null;
41
+ for (const line of lines) {
42
+ if (/^- /.test(line)) {
43
+ if (current) items.push(current);
44
+ current = [];
45
+ }
46
+ if (current) current.push(line);
47
+ else preamble.push(line);
48
+ }
49
+ if (current) items.push(current);
50
+ return {
51
+ preamble: preamble.join('\n'),
52
+ items: items.map((itemLines) => ({ lines: itemLines, text: itemLines.join('\n') })),
53
+ };
54
+ }
55
+
56
+ /** True when this patch item is an mcp-client row this plugin owns. */
57
+ function isOurMcpItem(itemText, serverName) {
58
+ if (!/^- insert:/.test(itemText)) return false;
59
+ if (!itemText.includes(MCP_CLIENT_PACKAGE)) return false;
60
+ const wanted = `${INSERT_ID_PREFIX}${serverName}`;
61
+ return new RegExp(`^\\s*-\\s+id:\\s*["']?${escapeRegExp(wanted)}["']?\\s*$`, 'm').test(itemText);
62
+ }
63
+
64
+ function escapeRegExp(value) {
65
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
+ }
67
+
68
+ /**
69
+ * True when this patch item is the enablement override the plugin manager
70
+ * appends for one of our rows.
71
+ *
72
+ * `pluginManager.setPluginEnabled` does not rewrite the row: it appends a
73
+ * separate top-level item that targets the row by id with nothing but
74
+ * `disabled:`. That item is a second thing on disk describing the same server,
75
+ * so removal has to take it too — a targeted patch entry pointing at an id no
76
+ * longer in the tree is a dangling override.
77
+ */
78
+ function isToggleOverrideItem(itemText, serverName) {
79
+ if (/^- insert:/.test(itemText)) return false;
80
+ if (!/^\s*disabled:\s*(true|false|null)\s*$/m.test(itemText)) return false;
81
+ const wanted = `${INSERT_ID_PREFIX}${serverName}`;
82
+ return new RegExp(`^-\\s+id:\\s*["']?${escapeRegExp(wanted)}["']?\\s*$`, 'm').test(itemText);
83
+ }
84
+
85
+ /** Is one of our servers currently disabled by a plugin-manager override? */
86
+ function isDisabledByOverride(itemText, serverName) {
87
+ if (!isToggleOverrideItem(itemText, serverName)) return false;
88
+ return /^\s*disabled:\s*true\s*$/m.test(itemText);
89
+ }
90
+
91
+ /**
92
+ * Switch one of our patch-layer servers on or off before its row has mounted.
93
+ *
94
+ * This writes the same shape `pluginManager.setPluginEnabled` writes: a
95
+ * top-level item targeting the row by id with nothing but `disabled:`. It is
96
+ * deliberately not a rewrite of the insert item, so the server definition the
97
+ * user pasted stays byte-identical and re-enabling restores the file exactly.
98
+ */
99
+ export function setDshPatchDisabled({ serverName, disabled, store, profileDir }) {
100
+ const path = profilePatchPath(profileDir);
101
+ if (!path || !existsSync(path)) return { ok: false, code: 'error.dsh.missing', error: 'no profile patch file' };
102
+ const before = readFileSync(path, 'utf8');
103
+ const { preamble, items } = splitPatchItems(before);
104
+ const hasRow = items.some((item) => isOurMcpItem(item.text, serverName));
105
+ if (!hasRow) return { ok: false, code: 'error.dsh.missing', error: `no patch row for ${serverName}` };
106
+ const others = items.filter((item) => !isToggleOverrideItem(item.text, serverName));
107
+ const id = `${INSERT_ID_PREFIX}${serverName}`;
108
+ const next = disabled ? [...others.map((item) => item.text.trimEnd()), `- id: ${id}\n disabled: true`] : others.map((item) => item.text.trimEnd());
109
+ const head = preamble.trimEnd();
110
+ const after = (head ? [head, ...next] : next).join('\n');
111
+ const text = after ? `${after}\n` : '';
112
+ if (text === before) return { ok: true, changed: false, path, serverName };
113
+ if (store && typeof store.backup === 'function') {
114
+ try {
115
+ store.backup(path, before);
116
+ } catch { /* the item is a reversible one-liner; a missing backup cannot lose data */ }
117
+ }
118
+ const tmp = `${path}.tmp-${process.pid}`;
119
+ writeFileSync(tmp, text, 'utf8');
120
+ renameSync(tmp, path);
121
+ return { ok: true, changed: true, path, serverName, restartRequired: true };
122
+ }
123
+
124
+ /** Render one `- insert:` patch item for a server. */
125
+ export function renderInsertItem(server) {
126
+ const config = encodeDshEntry(server);
127
+ const lines = [
128
+ '- insert:',
129
+ ` - id: ${INSERT_ID_PREFIX}${config.serverName}`,
130
+ ` name: '${MCP_CLIENT_PACKAGE}'`,
131
+ ' config:',
132
+ ];
133
+ const walk = (object, depth) => {
134
+ for (const [key, value] of Object.entries(object)) {
135
+ const pad = ' '.repeat(depth);
136
+ if (Array.isArray(value)) {
137
+ lines.push(`${pad}${key}:`);
138
+ for (const entry of value) lines.push(`${pad} - ${JSON.stringify(entry)}`);
139
+ } else if (value && typeof value === 'object') {
140
+ lines.push(`${pad}${key}:`);
141
+ walk(value, depth + 2);
142
+ } else {
143
+ lines.push(`${pad}${key}: ${typeof value === 'string' ? JSON.stringify(value) : value}`);
144
+ }
145
+ }
146
+ };
147
+ walk(config, 8);
148
+ return { text: lines.join('\n'), serverName: config.serverName };
149
+ }
150
+
151
+ /**
152
+ * Add one server to the patch layer, or replace the item this plugin wrote
153
+ * earlier for the same serverName.
154
+ *
155
+ * Returns `{ ok, changed, path, serverName }`; `changed:false` means the exact
156
+ * item was already present, so a repeated apply is a no-op rather than a
157
+ * duplicate row.
158
+ */
159
+ export function writeDshPatchServer({ server, store, profileDir }) {
160
+ const path = profilePatchPath(profileDir);
161
+ if (!path) return { ok: false, code: 'error.dsh.noProfile', error: 'cannot locate the active DSH profile' };
162
+ const existed = existsSync(path);
163
+ const before = existed ? readFileSync(path, 'utf8') : '';
164
+ const { text: itemText, serverName } = renderInsertItem(server);
165
+
166
+ const { preamble, items } = splitPatchItems(before);
167
+ const kept = [];
168
+ let replaced = false;
169
+ for (const item of items) {
170
+ if (isOurMcpItem(item.text, serverName)) {
171
+ if (item.text.trim() === itemText.trim()) replaced = true;
172
+ continue; // drop; the fresh item is appended below
173
+ }
174
+ kept.push(item.text);
175
+ }
176
+ if (replaced) return { ok: true, changed: false, path, serverName };
177
+
178
+ const head = preamble.trimEnd();
179
+ const chunks = [];
180
+ if (head) chunks.push(head);
181
+ for (const item of kept) chunks.push(item.trimEnd());
182
+ chunks.push(itemText);
183
+ const after = `${chunks.join('\n')}\n`;
184
+
185
+ if (store && typeof store.backup === 'function') {
186
+ try {
187
+ store.backup(path, before);
188
+ } catch { /* a missing backup must not block the write; the item is additive */ }
189
+ }
190
+ mkdirSync(dirname(path), { recursive: true });
191
+ const temp = `${path}.tmp-${process.pid}`;
192
+ writeFileSync(temp, after, 'utf8');
193
+ renameSync(temp, path);
194
+ return { ok: true, changed: true, path, serverName, created: !existed };
195
+ }
196
+
197
+ /** Remove the patch item this plugin wrote for one serverName. */
198
+ export function removeDshPatchServer({ serverName, store, profileDir }) {
199
+ const path = profilePatchPath(profileDir);
200
+ if (!path || !existsSync(path)) return { ok: true, changed: false, path };
201
+ const before = readFileSync(path, 'utf8');
202
+ const { preamble, items } = splitPatchItems(before);
203
+ // Take the enablement override with the row, or the override outlives its target.
204
+ const kept = items.filter((item) => !isOurMcpItem(item.text, serverName) && !isToggleOverrideItem(item.text, serverName));
205
+ if (kept.length === items.length) return { ok: true, changed: false, path };
206
+
207
+ if (store && typeof store.backup === 'function') {
208
+ try {
209
+ store.backup(path, before);
210
+ } catch { /* keep going: the write itself is still safe */ }
211
+ }
212
+ const head = preamble.trimEnd();
213
+ const chunks = head ? [head, ...kept.map((item) => item.text.trimEnd())] : kept.map((item) => item.text.trimEnd());
214
+ const after = chunks.length ? `${chunks.join('\n')}\n` : '';
215
+ const temp = `${path}.tmp-${process.pid}`;
216
+ writeFileSync(temp, after, 'utf8');
217
+ renameSync(temp, path);
218
+ return { ok: true, changed: true, path };
219
+ }
220
+
221
+ /** Does the patch layer already carry an item for this serverName? */
222
+ export function patchHasServer(serverName, profileDir) {
223
+ const path = profilePatchPath(profileDir);
224
+ if (!path || !existsSync(path)) return false;
225
+ const { items } = splitPatchItems(readFileSync(path, 'utf8'));
226
+ return items.some((item) => isOurMcpItem(item.text, serverName));
227
+ }
228
+
229
+ /**
230
+ * Read the MCP servers this plugin put in the patch layer.
231
+ *
232
+ * These are *pending*: the row exists on disk but only becomes a live entry
233
+ * after the Loader reconciles (a restart). `scanAll` merges them with the live
234
+ * rows, live first, so a server is never counted twice once it has taken
235
+ * effect, and the UI can still show one that was just written.
236
+ */
237
+ export function readDshPatchServers(profileDir) {
238
+ const path = profilePatchPath(profileDir);
239
+ if (!path || !existsSync(path)) return [];
240
+ const out = [];
241
+ const { items } = splitPatchItems(readFileSync(path, 'utf8'));
242
+ for (const item of items) {
243
+ if (!/^- insert:/.test(item.text) || !item.text.includes(MCP_CLIENT_PACKAGE)) continue;
244
+ const idMatch = item.text.match(/^\s*-\s+id:\s*["']?([^"'\s]+)["']?\s*$/m);
245
+ if (!idMatch || !idMatch[1].startsWith(INSERT_ID_PREFIX)) continue;
246
+ const server = parseInsertItem(item.text);
247
+ if (!server) continue;
248
+ // A plugin-manager override can switch this row off without touching it.
249
+ const disabled = items.some((other) => isDisabledByOverride(other.text, server.name));
250
+ out.push({ ...server, enabled: !disabled, pending: true, patchPath: path, patchId: idMatch[1] });
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * Pull the `config:` block out of one insert item.
257
+ *
258
+ * The block this plugin writes is deliberately shallow (scalars, string lists,
259
+ * and one level of string maps for env/headers), so a small purpose-built
260
+ * reader is enough and avoids adding a YAML dependency to the bundle.
261
+ */
262
+ function parseInsertItem(itemText) {
263
+ const lines = itemText.split('\n');
264
+ const start = lines.findIndex((line) => /^\s+config:\s*$/.test(line));
265
+ if (start < 0) return null;
266
+ const configIndent = lines[start].match(/^\s*/)[0].length;
267
+ const config = {};
268
+ let currentKey = null;
269
+ let currentIndent = -1;
270
+ for (let index = start + 1; index < lines.length; index += 1) {
271
+ const line = lines[index];
272
+ if (!line.trim()) continue;
273
+ const indent = line.match(/^\s*/)[0].length;
274
+ if (indent <= configIndent) break;
275
+ const listItem = line.match(/^\s*-\s+(.*)$/);
276
+ if (listItem) {
277
+ if (currentKey) {
278
+ /* A key with no inline value is a map or a list; the first item decides. */
279
+ if (!Array.isArray(config[currentKey])) config[currentKey] = [];
280
+ config[currentKey].push(unquote(listItem[1]));
281
+ }
282
+ continue;
283
+ }
284
+ const pair = line.match(/^\s*([A-Za-z0-9_.-]+):\s*(.*)$/);
285
+ if (!pair) continue;
286
+ const [, key, rest] = pair;
287
+ if (rest === '') {
288
+ currentKey = key;
289
+ currentIndent = indent;
290
+ config[key] = config[key] ?? {};
291
+ continue;
292
+ }
293
+ if (currentKey && indent > currentIndent && typeof config[currentKey] === 'object' && !Array.isArray(config[currentKey])) {
294
+ config[currentKey][key] = unquote(rest);
295
+ continue;
296
+ }
297
+ currentKey = null;
298
+ config[key] = rest === '[]' ? [] : unquote(rest);
299
+ }
300
+ if (!config.serverName) return null;
301
+ return {
302
+ name: config.serverName,
303
+ transport: config.transport === 'streamable-http' ? 'http-stream' : 'stdio',
304
+ command: config.command,
305
+ args: Array.isArray(config.args) ? config.args : config.args ? [config.args] : [],
306
+ env: typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {},
307
+ headers: typeof config.headers === 'object' && !Array.isArray(config.headers) ? config.headers : {},
308
+ url: config.url,
309
+ timeoutMs: typeof config.toolCallTimeoutMs === 'number' ? config.toolCallTimeoutMs : undefined,
310
+ enabled: true,
311
+ pending: true,
312
+ };
313
+ }
314
+
315
+ function unquote(value) {
316
+ const text = String(value).trim();
317
+ if (text === 'true') return true;
318
+ if (text === 'false') return false;
319
+ if (/^-?\d+$/.test(text)) return Number(text);
320
+ if (/^'.*'$/.test(text)) return text.slice(1, -1).replace(/''/g, "'");
321
+ if (/^".*"$/.test(text)) {
322
+ try {
323
+ return JSON.parse(text);
324
+ } catch {
325
+ return text.slice(1, -1);
326
+ }
327
+ }
328
+ return text;
329
+ }
package/lib/dsh.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * DeepSeek Harness as one more MCP target.
3
+ *
4
+ * DSH stores MCP servers as `@deepseek-ai/dsh-mcp-client` rows in a cordis
5
+ * composition, not as JSON. Writing those files by hand would mean guessing at
6
+ * YAML form (including `!!js` expressions) and requiring a restart to see any
7
+ * effect. `configEditor.edit()` does it properly: it validates the config
8
+ * against the plugin's schema, persists it, and reconciles the Loader, and
9
+ * mcp-client applies changes hot.
10
+ *
11
+ * Two constraints come from mcp-client's own schema and are enforced here
12
+ * rather than discovered as a startup failure:
13
+ * - `serverName` must match `[A-Za-z0-9_-]{1,32}` and be unique per scope
14
+ * - `transport` is `stdio` or `streamable-http`; there is no `sse` and no
15
+ * separate timeout field beyond `toolCallTimeoutMs`
16
+ */
17
+
18
+ import { dshMountName, sanitizeName } from './canonical.js';
19
+
20
+ export const MCP_CLIENT_PACKAGE = '@deepseek-ai/dsh-mcp-client';
21
+ const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
22
+
23
+ /**
24
+ * The plugin package a Loader entry instantiates.
25
+ *
26
+ * Read `options.name`, not `name`: the live Loader entry carries no `name`
27
+ * property at all (verified on a running Host — `entry.name` is `undefined`
28
+ * while `entry.options.name` is `'@deepseek-ai/dsh-mcp-client'`). Checking
29
+ * `entry.name` silently matched zero rows, which made the scanner believe every
30
+ * DSH server was still unreconciled in the patch layer and report it as
31
+ * needing a restart. Accept both shapes so a future Loader that sets `name`
32
+ * keeps working.
33
+ */
34
+ function entryPackageName(entry) {
35
+ if (!entry) return '';
36
+ if (typeof entry.name === 'string') return entry.name;
37
+ const options = entry.options;
38
+ return options && typeof options.name === 'string' ? options.name : '';
39
+ }
40
+
41
+ /** Every composition row that is an MCP client. */
42
+ export function mcpEntries(configEditor) {
43
+ if (!configEditor || typeof configEditor.configuration !== 'function') return [];
44
+ try {
45
+ return configEditor.configuration().filter((row) => {
46
+ const name = entryPackageName(row && row.entry);
47
+ return name === MCP_CLIENT_PACKAGE || name.endsWith('/dsh-mcp-client');
48
+ });
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /** Decode our canonical model from one mcp-client row config. */
55
+ export function decodeDshEntry(entry, config) {
56
+ const raw = config && typeof config === 'object' ? config : {};
57
+ const transport = raw.transport === 'streamable-http' ? 'http-stream' : 'stdio';
58
+ const env = raw.env && typeof raw.env === 'object' ? { ...raw.env } : {};
59
+ const headers = raw.headers && typeof raw.headers === 'object' ? { ...raw.headers } : {};
60
+ return {
61
+ name: raw.serverName ?? entry?.id ?? 'unknown',
62
+ entryId: entry?.id,
63
+ transport,
64
+ command: raw.command ?? '',
65
+ args: Array.isArray(raw.args) ? raw.args.map(String) : [],
66
+ env,
67
+ cwd: raw.cwd,
68
+ url: raw.url,
69
+ headers,
70
+ timeoutMs: typeof raw.toolCallTimeoutMs === 'number' ? raw.toolCallTimeoutMs : 60000,
71
+ enabled: !(entry && entry.disabled === true),
72
+ autoApprove: [],
73
+ extra: {
74
+ failOnStartupError: raw.failOnStartupError,
75
+ reconnect: raw.reconnect,
76
+ },
77
+ secrets: [
78
+ ...Object.entries(env).map(([key, value]) => ({ where: 'env', key, value })),
79
+ ...Object.entries(headers).map(([key, value]) => ({ where: 'headers', key, value })),
80
+ ]
81
+ .filter((item) => /token|key|secret|password|auth|cookie/i.test(item.key) && typeof item.value === 'string' && item.value.length >= 12)
82
+ .map(({ where, key }) => ({ where, key })),
83
+ raw,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Encode the canonical model into an mcp-client config object.
89
+ *
90
+ * mcp-client has exactly two transports. A canonical `http-sse` has to become
91
+ * `streamable-http`: collapsing it to `stdio` would emit a config with no
92
+ * command (a row that never starts), which is worse than a transport hint the
93
+ * connection may negotiate away.
94
+ */
95
+ export function encodeDshEntry(server) {
96
+ const remote = server.transport === 'http-stream' || server.transport === 'http-sse';
97
+ /*
98
+ * A caller may pass an already-mapped mount name (the scan reports it as
99
+ * `mountName`); otherwise derive the same mapping from the display name so a
100
+ * write and a later lookup always agree.
101
+ */
102
+ const mountName = server.mountName ? sanitizeName(server.mountName) : dshMountName(server.serverName ?? server.name);
103
+ const config = { serverName: mountName, transport: remote ? 'streamable-http' : 'stdio' };
104
+ if (!remote) {
105
+ config.command = server.command;
106
+ if (server.args && server.args.length) config.args = [...server.args];
107
+ if (server.env && Object.keys(server.env).length) config.env = { ...server.env };
108
+ if (server.cwd) config.cwd = server.cwd;
109
+ } else {
110
+ config.url = server.url;
111
+ if (server.headers && Object.keys(server.headers).length) config.headers = { ...server.headers };
112
+ }
113
+ if (typeof server.timeoutMs === 'number' && server.timeoutMs > 0) config.toolCallTimeoutMs = server.timeoutMs;
114
+ for (const key of ['failOnStartupError', 'reconnect']) {
115
+ const value = server.extra?.[key];
116
+ if (value !== undefined) config[key] = value;
117
+ }
118
+ return config;
119
+ }
120
+
121
+ /**
122
+ * Add or replace one MCP server row. `previous` is the row to replace; when
123
+ * absent a fresh row id is derived from the server name.
124
+ */
125
+ export async function upsertDshServer(configEditor, { server, previous }) {
126
+ const config = encodeDshEntry(server);
127
+ if (!SERVER_NAME_RE.test(config.serverName)) {
128
+ return { ok: false, code: 'error.dsh.badServerName', error: `serverName must match [A-Za-z0-9_-]{1,32}, got ${config.serverName}` };
129
+ }
130
+ const rows = mcpEntries(configEditor);
131
+ const clash = rows.find((row) => row.entry && row.entry.id !== previous?.entry?.id && decodeDshEntry(row.entry, { ...row.inherited, ...row.override }).name === config.serverName);
132
+ if (clash) {
133
+ return { ok: false, code: 'error.dsh.duplicateName', error: `another MCP row already uses serverName ${config.serverName}` };
134
+ }
135
+ /*
136
+ * DSH identifies a server by serverName, so a coerced name is a different
137
+ * server than the one the user named. Report it instead of renaming quietly:
138
+ * a 32-char truncation can also collide with a row that already exists, and
139
+ * the user has to know before the row lands.
140
+ *
141
+ * One case is NOT a coercion and must stay silent: a name the harness cannot
142
+ * accept at all (CJK, spaces, punctuation elsewhere) is mapped by
143
+ * `dshMountName` on every path — write, scan, toggle, remove — so reporting
144
+ * that mapping as a rename would look like a bug on every CJK server. The
145
+ * test is whether the harness name is EXACTLY what plain sanitising produces:
146
+ * - too long, ASCII → sanitize alone truncates, so the characters were
147
+ * lost and the user has to hear about it
148
+ * - CJK / punctuation → sanitize alone would collide, so dshMountName
149
+ * appends a fingerprint and nothing was lost
150
+ */
151
+ const requested = String(server.serverName ?? server.name ?? '').trim();
152
+ const truncated = requested && sanitizeName(requested, '') === config.serverName;
153
+ const renamed = requested && requested !== config.serverName && truncated
154
+ ? { from: requested, to: config.serverName }
155
+ : undefined;
156
+ if (previous) {
157
+ try {
158
+ await configEditor.edit(previous.entry, (current, inherited) => ({ ...inherited, ...current, ...config }));
159
+ } catch (error) {
160
+ return { ok: false, code: 'error.dsh.write', error: String(error && error.message ? error.message : error) };
161
+ }
162
+ return { ok: true, entryId: previous.entry.id, serverName: config.serverName, ...(renamed ? { renamed } : {}) };
163
+ }
164
+ return { ok: false, code: 'error.dsh.insertUnsupported', error: 'adding a new MCP row requires the patch layer; see the generated snippet' };
165
+ }
166
+
167
+ /** Remove one MCP server row. */
168
+ export async function removeDshServer(configEditor, row) {
169
+ if (!row) return { ok: false, code: 'error.dsh.missing', error: 'no MCP row to remove' };
170
+ try {
171
+ await configEditor.edit(row.entry, (current, inherited) => {
172
+ const next = { ...inherited, ...current };
173
+ delete next.serverName;
174
+ return next;
175
+ });
176
+ } catch (error) {
177
+ return { ok: false, code: 'error.dsh.write', error: String(error && error.message ? error.message : error) };
178
+ }
179
+ return { ok: true };
180
+ }
181
+
182
+ /**
183
+ * Mount or unmount one MCP row inside DSH.
184
+ *
185
+ * This is deliberately NOT a config write. mcp-client's schema has no
186
+ * `enabled` field, so "off" cannot be expressed as configuration; the Loader
187
+ * row's own `disabled` flag is the only real off switch, and `pluginManager`
188
+ * owns it. Using it means the process really disposes the plugin (its tools
189
+ * disappear) and really re-initialises it, and that nothing outside DSH is
190
+ * touched — no other client's config file is rewritten.
191
+ *
192
+ * `entryId` is the Loader row identity from `listPlugins`/`configuration()`.
193
+ * A server that only exists in the patch layer has no identity until the
194
+ * Loader reconciles it, so the caller has to report that instead of guessing.
195
+ */
196
+ export async function setDshServerEnabled(pluginManager, entryId, enabled) {
197
+ if (!pluginManager || typeof pluginManager.setPluginEnabled !== 'function') {
198
+ return { ok: false, code: 'error.dsh.noManager', error: 'the plugin manager service is not available in this process' };
199
+ }
200
+ if (!entryId) {
201
+ return {
202
+ ok: false,
203
+ code: 'error.dsh.noEntry',
204
+ error: 'this server has no Loader row yet',
205
+ hint: 'it was written to the profile patch and the Loader has not reconciled it; reload the page to pick up the live row',
206
+ };
207
+ }
208
+ let result;
209
+ try {
210
+ result = await pluginManager.setPluginEnabled(entryId, enabled === true);
211
+ } catch (error) {
212
+ return { ok: false, code: 'error.dsh.toggle', error: String(error && error.message ? error.message : error) };
213
+ }
214
+ const application = result && result.application;
215
+ if (application === 'overridden') {
216
+ // A higher-priority composition layer decides this row; flipping it here
217
+ // would appear to succeed and then revert on the next reload.
218
+ return { ok: false, code: 'error.dsh.overridden', error: result.error?.diagnostic ?? 'a higher-priority layer overrides this row' };
219
+ }
220
+ if (application === 'failed' || application === 'cancelled') {
221
+ return {
222
+ ok: false,
223
+ code: 'error.dsh.toggle',
224
+ error: result.error?.diagnostic ?? `the plugin manager answered ${application}`,
225
+ managerCode: result.error?.code,
226
+ };
227
+ }
228
+ return {
229
+ ok: true,
230
+ entryId,
231
+ enabled: enabled === true,
232
+ enabledOnDisk: result?.enabled,
233
+ changed: result?.changed === true,
234
+ restartRequired: application === 'restart-required',
235
+ warnings: result?.warnings ?? [],
236
+ };
237
+ }
238
+
239
+ /** YAML snippet a user can paste to add a new row (we cannot insert rows). */
240
+ export function dshSnippet(server) {
241
+ const config = encodeDshEntry(server);
242
+ const lines = [`- insert:`, ` - id: mcp-${config.serverName}`, ` name: '${MCP_CLIENT_PACKAGE}'`, ` config:`];
243
+ const walk = (object, depth) => {
244
+ for (const [key, value] of Object.entries(object)) {
245
+ const pad = ' '.repeat(depth);
246
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
247
+ lines.push(`${pad}${key}:`);
248
+ walk(value, depth + 2);
249
+ } else if (Array.isArray(value)) {
250
+ lines.push(`${pad}${key}:`);
251
+ for (const item of value) lines.push(`${pad} - ${JSON.stringify(item)}`);
252
+ } else {
253
+ lines.push(`${pad}${key}: ${typeof value === 'string' ? JSON.stringify(value) : value}`);
254
+ }
255
+ }
256
+ };
257
+ walk(config, 8);
258
+ return `${lines.join('\n')}\n`;
259
+ }