@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.
- package/LICENSE +21 -0
- package/README.md +351 -2
- package/README.zh.md +213 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/adapters.js +346 -0
- package/lib/canonical.js +488 -0
- package/lib/client.js +1151 -0
- package/lib/dsh-patch.js +329 -0
- package/lib/dsh.js +259 -0
- package/lib/index.js +495 -0
- package/lib/ops.js +325 -0
- package/lib/probe.js +252 -0
- package/lib/scan.js +353 -0
- package/lib/store.js +257 -0
- package/lib/surgical.js +581 -0
- package/lib/toggle.js +194 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
package/lib/dsh-patch.js
ADDED
|
@@ -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
|
+
}
|