@moon16u/dsh-plugin-mcp-console 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,129 @@
1
+ /**
2
+ * Host-side types for @moon16u/dsh-plugin-mcp-console.
3
+ * Hand-maintained alongside the hand-written lib/*.js (dsh-pouch convention).
4
+ */
5
+ import type { Context, Fiber } from "@deepseek-ai/cordis";
6
+
7
+ /** serverName contract, mirrored from the official mcp-client. */
8
+ export type ServerScope = "global" | "project";
9
+
10
+ /** Console-stored stdio server config (own storage format). */
11
+ export interface StdioServerConfig {
12
+ name: string;
13
+ enabled: boolean;
14
+ transport: "stdio";
15
+ command: string;
16
+ args: string[];
17
+ env: Record<string, string>;
18
+ cwd: string;
19
+ scope: ServerScope;
20
+ toolCallTimeoutMs: number;
21
+ failOnStartupError: boolean;
22
+ reconnect?: Record<string, unknown>;
23
+ }
24
+
25
+ /** Console-stored streamable-http server config. */
26
+ export interface HttpServerConfig {
27
+ name: string;
28
+ enabled: boolean;
29
+ transport: "streamable-http";
30
+ url: string;
31
+ headers: Record<string, string>;
32
+ scope: ServerScope;
33
+ toolCallTimeoutMs: number;
34
+ failOnStartupError: boolean;
35
+ reconnect?: Record<string, unknown>;
36
+ /** Disabled public tool names (per-tool switch, mcp-manager-gui-spec §3.4). */
37
+ disabledTools: string[];
38
+ }
39
+
40
+ export type ServerConfig = StdioServerConfig | HttpServerConfig;
41
+
42
+ /** One ledger entry: live assembly state for a managed server. */
43
+ export interface LedgerEntry {
44
+ fiber: Fiber | null;
45
+ config: ServerConfig;
46
+ toolNames: Set<string>;
47
+ error: string | null;
48
+ pending: boolean;
49
+ loadedAt: number;
50
+ }
51
+
52
+ /** Machine-readable error codes mapped onto HTTP statuses. */
53
+ export type ConsoleErrorCode = "invalid" | "conflict" | "not_found" | "internal";
54
+
55
+ export declare class ConsoleError extends Error {
56
+ code: ConsoleErrorCode;
57
+ constructor(code: ConsoleErrorCode, message: string);
58
+ }
59
+
60
+ export declare class Orchestrator {
61
+ constructor(ctx: Context, options: { globalStore: Store; onChange?: () => void });
62
+ ledger: Map<string, LedgerEntry>;
63
+ profileDeclared: Set<string>;
64
+ bootstrap(): Promise<void>;
65
+ add(server: Partial<ServerConfig>): Promise<{ config: Record<string, unknown> }>;
66
+ update(name: string, patch: Record<string, unknown>): Promise<{ config: Record<string, unknown> }>;
67
+ remove(name: string): Promise<{ removed: string }>;
68
+ setEnabled(name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }>;
69
+ reconnect(name: string): Promise<{ name: string }>;
70
+ refresh(): Promise<{ restarted: string[] }>;
71
+ disabledToolSet: Set<string>;
72
+ rebuildDisabledToolSet(): void;
73
+ toolGuard(execution: { name?: string }): string | undefined;
74
+ importServers(parsed: { name: string; config: Partial<ServerConfig> }[]): Promise<{
75
+ added: Record<string, unknown>[];
76
+ skipped: { name: string; reason: string }[];
77
+ }>;
78
+ storedServers(): Map<string, ServerConfig>;
79
+ toolsOf(name: string): string[];
80
+ externalServerNames(): Set<string>;
81
+ }
82
+
83
+ export declare class Store {
84
+ constructor(filePath: string);
85
+ path: string;
86
+ lastError: string | null;
87
+ read(): { version: number; servers: Record<string, Partial<ServerConfig>>; ui?: Record<string, unknown> };
88
+ mutate(mutate: (servers: Record<string, Partial<ServerConfig>>) => unknown): unknown;
89
+ writeUi(ui: Record<string, unknown>): Record<string, unknown>;
90
+ read(): { version: number; servers: Record<string, Partial<ServerConfig>>; ui?: Record<string, unknown> };
91
+ }
92
+
93
+ /** One tool as the panel sees it (mcp-manager-gui-spec §4 McpTool). */
94
+ export interface ToolView {
95
+ /** Public tool name (mcp__<server>__<raw>, possibly hash-suffixed). */
96
+ name: string;
97
+ /** Display label: the public name without the server prefix. */
98
+ label: string;
99
+ enabled: boolean;
100
+ }
101
+
102
+ /** API snapshot (GET /servers payload shape). */
103
+ export interface ServerStatusView extends Record<string, unknown> {
104
+ name: string;
105
+ status: "running" | "connecting" | "loading" | "disabled" | "failed";
106
+ toolCount: number;
107
+ enabledToolCount: number;
108
+ tools: ToolView[];
109
+ disabledTools: string[];
110
+ error: string | null;
111
+ }
112
+
113
+ export interface Snapshot {
114
+ version: number;
115
+ servers: ServerStatusView[];
116
+ external: string[];
117
+ storeErrors: string[];
118
+ clientError: string | null;
119
+ }
120
+
121
+ export declare function computeSnapshot(orchestrator: Orchestrator): Snapshot;
122
+ export declare function parseMcpServers(text: string): {
123
+ servers: { name: string; config: Partial<ServerConfig> }[];
124
+ errors: string[];
125
+ };
126
+ export declare function validateServerConfig(server: unknown): { config?: ServerConfig; error?: string };
127
+ export declare function scanProfileMcpServerNames(dshHome?: string): Set<string>;
128
+ export declare const name: string;
129
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Auto-ingest engine + YAML export (dsh-mcp-ingest-export-plan.md).
3
+ *
4
+ * The profile's `cordis.patch.yml` is the official static way to declare MCP
5
+ * servers, but entries there are invisible to the console's dynamic
6
+ * management. This module moves them across, both ways:
7
+ *
8
+ * ingest cordis.patch.yml -> ~/.dsh/dsh-mcp.json (+ physical removal of
9
+ * the migrated blocks from the YAML, preserving every other entry,
10
+ * comment, and blank line)
11
+ * export ~/.dsh/dsh-mcp.json -> cordis.patch.yml (append standard
12
+ * mcp-client entries, for uninstall/backup reversibility)
13
+ *
14
+ * The YAML edit is line-based and comment-preserving — no YAML library: the
15
+ * patch file's shape (top-level list of `- id:`/`- insert:` items) is stable,
16
+ * and a tolerant block parser keeps the file's own formatting intact.
17
+ *
18
+ * Safety order (ingest): parse+extract -> persist to store -> physically
19
+ * trim YAML. If the store write throws, the YAML is left untouched, so a
20
+ * failed ingest never loses configuration. Deletions (ingest trim, delete
21
+ * server) are the user's explicit intent — no backup files are kept.
22
+ */
23
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { validateServerConfig } from "./clientAdapter.js";
27
+
28
+ const MCP_CLIENT_NAME = /name:\s*['"]?@deepseek-ai\/dsh-mcp-client['"]?/;
29
+
30
+ /** The profile patch layer this console manages (web surface). */
31
+ export function defaultProfilePatchYamlPath(dshHome, profile = "web") {
32
+ const root = dshHome ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
33
+ return join(root, "profiles", profile, "cordis.patch.yml");
34
+ }
35
+
36
+ function indentOf(line) {
37
+ const match = /^(\s*)/.exec(line);
38
+ return match ? match[1].length : 0;
39
+ }
40
+
41
+ function scalar(raw) {
42
+ const value = raw.trim();
43
+ if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
44
+ return value.slice(1, -1);
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * Parse one `key: value` (or `- key: value`) line into its parts.
51
+ * Returns null for blank/comment/continuation-less lines without a colon.
52
+ */
53
+ function keyValue(line) {
54
+ const stripped = line.replace(/^(\s*)-\s+/, "$1");
55
+ const match = /^\s*([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(stripped);
56
+ if (!match) return null;
57
+ return { key: match[1], value: scalar(match[2] || ""), indent: indentOf(stripped) };
58
+ }
59
+
60
+ /**
61
+ * Split the patch file into top-level list-item blocks.
62
+ *
63
+ * Comment/blank lines BETWEEN items buffer as "pending"; when the next item
64
+ * starts, the comment run after the last pending blank becomes that item's
65
+ * LEADING lines (its header comment) so removing an item takes its own
66
+ * comment with it and never its neighbour's. Earlier pending lines (file
67
+ * headers, spacing) stay unassigned and are always kept.
68
+ */
69
+ function splitTopLevelItems(lines) {
70
+ const items = [];
71
+ const pending = [];
72
+ let current = null;
73
+ const itemIndent = (() => {
74
+ let min = null;
75
+ for (const line of lines) {
76
+ if (!/^\s*-\s/.test(line)) continue;
77
+ const indent = indentOf(line);
78
+ if (min === null || indent < min) min = indent;
79
+ }
80
+ return min;
81
+ })();
82
+ if (itemIndent === null) return items;
83
+ for (let index = 0; index < lines.length; index += 1) {
84
+ const line = lines[index];
85
+ if (/^\s*-\s/.test(line) && indentOf(line) === itemIndent) {
86
+ // flush: the comment run after the last pending blank is my header
87
+ let lastBlank = -1;
88
+ for (let p = 0; p < pending.length; p += 1) {
89
+ if (pending[p].trim() === "") lastBlank = p;
90
+ }
91
+ const leading = pending.slice(lastBlank + 1).filter((l) => l.trim().startsWith("#"));
92
+ current = { start: index - leading.length, lines: [...leading, line] };
93
+ items.push(current);
94
+ pending.length = 0;
95
+ } else if (/^\s*#/.test(line) || line.trim() === "") {
96
+ pending.push(line);
97
+ } else if (current) {
98
+ current.lines.push(line);
99
+ }
100
+ // lines before the first item (file header) are left unassigned: kept
101
+ }
102
+ return items;
103
+ }
104
+
105
+ /**
106
+ * Split one top-level item into its nested entries (`- id:` siblings under
107
+ * `- insert:`). The flat form (the item itself names the plugin) returns a
108
+ * single entry covering the whole item. Returns { shell, entries } where
109
+ * shell is the lines before the first nested entry (the `- insert:` header).
110
+ */
111
+ function splitItemEntries(itemLines) {
112
+ const firstIndent = indentOf(itemLines[0]);
113
+ let entryIndent = null;
114
+ for (const line of itemLines.slice(1)) {
115
+ if (/^\s*-\s/.test(line)) {
116
+ const indent = indentOf(line);
117
+ if (indent > firstIndent && (entryIndent === null || indent < entryIndent)) entryIndent = indent;
118
+ }
119
+ }
120
+ if (entryIndent === null) {
121
+ return { shell: [], entries: [{ lines: itemLines }] };
122
+ }
123
+ const shell = [];
124
+ const entries = [];
125
+ let current = null;
126
+ for (const line of itemLines) {
127
+ if (/^\s*-\s/.test(line) && indentOf(line) === entryIndent) {
128
+ current = { lines: [line] };
129
+ entries.push(current);
130
+ } else if (current) {
131
+ current.lines.push(line);
132
+ } else {
133
+ shell.push(line);
134
+ }
135
+ }
136
+ return { shell, entries };
137
+ }
138
+
139
+ /**
140
+ * Extract one MCP item's server config from its block lines.
141
+ * Understands the nesting the official docs use:
142
+ * - insert:
143
+ * - id: mcp-x
144
+ * name: '@deepseek-ai/dsh-mcp-client'
145
+ * config:
146
+ * serverName: x
147
+ * transport: stdio
148
+ * command: npx
149
+ * args:
150
+ * - -y
151
+ * - pkg
152
+ * env:
153
+ * KEY: value
154
+ * plus the flat form (`- name: '@deepseek-ai/dsh-mcp-client'` with config at
155
+ * the same level). List values (`args`) and nested maps (`env`, `headers`,
156
+ * `reconnect`) are collected.
157
+ */
158
+ function blockToServerConfig(itemLines) {
159
+ // locate the name line to anchor the config scope
160
+ let nameIndex = -1;
161
+ for (let index = 0; index < itemLines.length; index += 1) {
162
+ if (MCP_CLIENT_NAME.test(itemLines[index])) {
163
+ nameIndex = index;
164
+ break;
165
+ }
166
+ }
167
+ if (nameIndex === -1) return null;
168
+ const nameIndent = indentOf(itemLines[nameIndex]);
169
+
170
+ const config = {};
171
+ let args = null;
172
+ const maps = {};
173
+ let inList = null;
174
+ let inMap = null;
175
+
176
+ for (let index = nameIndex + 1; index < itemLines.length; index += 1) {
177
+ const line = itemLines[index];
178
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
179
+ const indent = indentOf(line);
180
+
181
+ // list item (- value)
182
+ const listMatch = /^(\s*)-\s+(.*)$/.exec(line);
183
+ if (listMatch && (inList || inMap) && indent >= (inList?.indent ?? inMap.indent)) {
184
+ if (inList) {
185
+ config[inList.key] = config[inList.key] ?? [];
186
+ config[inList.key].push(scalar(listMatch[2]));
187
+ continue;
188
+ }
189
+ if (inMap) {
190
+ const pair = /^([^:]+):\s*(.*)$/.exec(listMatch[2]);
191
+ if (pair) maps[inMap.key][scalar(pair[1])] = scalar(pair[2] || "");
192
+ continue;
193
+ }
194
+ }
195
+
196
+ const entry = keyValue(line);
197
+ if (inMap) {
198
+ if (entry && entry.indent > inMap.indent) {
199
+ maps[inMap.key][entry.key] = entry.value;
200
+ continue;
201
+ }
202
+ // same-level or dedented key: the map scope is over
203
+ inMap = null;
204
+ }
205
+ if (!entry || entry.indent < nameIndent) {
206
+ // dedent past the name line: this block's fields are over
207
+ inList = null;
208
+ inMap = null;
209
+ continue;
210
+ }
211
+ if (entry.indent === nameIndent) {
212
+ // sibling of name: (id, config:) — config handled via deeper lines
213
+ if (entry.key === "id") config.id = entry.value;
214
+ if (entry.key === "serverName") config.serverName = entry.value;
215
+ if (entry.key === "transport") config.transport = entry.value;
216
+ if (entry.key === "command") config.command = entry.value;
217
+ if (entry.key === "url") config.url = entry.value;
218
+ if (entry.key === "cwd") config.cwd = entry.value;
219
+ if (entry.key === "toolCallTimeoutMs") config.toolCallTimeoutMs = Number(entry.value) || undefined;
220
+ if (entry.key === "failOnStartupError") config.failOnStartupError = entry.value === "true";
221
+ if (entry.key === "args" && entry.value === "") { inList = { key: "args", indent: entry.indent }; }
222
+ if (entry.key === "env" && entry.value === "") { inMap = { key: "env", indent: entry.indent }; maps.env = {}; }
223
+ if (entry.key === "headers" && entry.value === "") { inMap = { key: "headers", indent: entry.indent }; maps.headers = {}; }
224
+ continue;
225
+ }
226
+ // deeper than name: config fields (or nested under config:)
227
+ if (entry.key === "serverName") config.serverName = entry.value;
228
+ else if (entry.key === "transport") config.transport = entry.value;
229
+ else if (entry.key === "command") config.command = entry.value;
230
+ else if (entry.key === "url") config.url = entry.value;
231
+ else if (entry.key === "cwd") config.cwd = entry.value;
232
+ else if (entry.key === "toolCallTimeoutMs") config.toolCallTimeoutMs = Number(entry.value) || undefined;
233
+ else if (entry.key === "failOnStartupError") config.failOnStartupError = entry.value === "true";
234
+ else if (entry.key === "args" && entry.value === "") { inList = { key: "args", indent: entry.indent }; config.args = []; }
235
+ else if (entry.key === "env" && entry.value === "") { inMap = { key: "env", indent: entry.indent }; maps.env = {}; }
236
+ else if (entry.key === "headers" && entry.value === "") { inMap = { key: "headers", indent: entry.indent }; maps.headers = {}; }
237
+ }
238
+ if (maps.env && Object.keys(maps.env).length > 0) config.env = maps.env;
239
+ if (maps.headers && Object.keys(maps.headers).length > 0) config.headers = maps.headers;
240
+ if (Array.isArray(config.args) && config.args.length === 0) delete config.args;
241
+ if (!config.serverName) return null;
242
+ return config;
243
+ }
244
+
245
+ /**
246
+ * Ingest: scan the profile patch YAML, move every mcp-client entry into the
247
+ * store, and physically remove those blocks from the YAML.
248
+ *
249
+ * @param {object} options
250
+ * @param {import("./store.js").Store} options.store
251
+ * @param {string} [options.yamlPath]
252
+ * @returns {Promise<{ ingested: string[], skipped: {name: string, reason: string}[], cleanedYaml: boolean }>}
253
+ */
254
+ export async function ingestProfileMcpEntries({ store, yamlPath }) {
255
+ const targetPath = yamlPath ?? defaultProfilePatchYamlPath();
256
+ if (!existsSync(targetPath)) {
257
+ return { ingested: [], skipped: [], cleanedYaml: false };
258
+ }
259
+ const rawYaml = readFileSync(targetPath, "utf8");
260
+ const lines = rawYaml.split("\n");
261
+ const items = splitTopLevelItems(lines);
262
+ // entry-level blocks: one `- insert:` item may carry several entries and
263
+ // only some of them are mcp-client. Entry offsets are resolved inside the
264
+ // item's own line window (items are ordered and non-overlapping).
265
+ const blocks = [];
266
+ for (const item of items) {
267
+ if (!item.lines.some((line) => MCP_CLIENT_NAME.test(line))) continue;
268
+ const { entries } = splitItemEntries(item.lines);
269
+ let offset = item.start;
270
+ for (const entry of entries) {
271
+ while (offset < item.start + item.lines.length && lines[offset] !== entry.lines[0]) offset += 1;
272
+ if (entry.lines.some((line) => MCP_CLIENT_NAME.test(line))) {
273
+ blocks.push({ item, entry, start: offset, config: blockToServerConfig(entry.lines) });
274
+ }
275
+ offset += 1;
276
+ }
277
+ }
278
+ if (blocks.length === 0) {
279
+ return { ingested: [], skipped: [], cleanedYaml: false };
280
+ }
281
+
282
+ const ingested = [];
283
+ const skipped = [];
284
+ const committed = store.mutate((servers) => {
285
+ for (const block of blocks) {
286
+ const name = block.config?.serverName;
287
+ if (!name) {
288
+ skipped.push({ name: "(unnamed)", reason: "entry has no serverName" });
289
+ continue;
290
+ }
291
+ const { config: validated, error } = validateServerConfig({
292
+ ...block.config,
293
+ name,
294
+ enabled: true,
295
+ scope: "global",
296
+ });
297
+ if (error) {
298
+ skipped.push({ name, reason: error });
299
+ continue;
300
+ }
301
+ // an existing entry in the store wins (it is already managed); the
302
+ // YAML block is still removed — the store is the single source now
303
+ if (servers[name] === undefined) {
304
+ const { name: _strip, ...rest } = validated;
305
+ servers[name] = rest;
306
+ ingested.push(name);
307
+ } else {
308
+ skipped.push({ name, reason: "already present in the console store (store wins)" });
309
+ }
310
+ }
311
+ });
312
+ if (committed === false) {
313
+ return { ingested, skipped, cleanedYaml: false };
314
+ }
315
+
316
+ // physically remove the migrated entries (keep everything else verbatim).
317
+ // An unparseable MCP entry (no serverName) stays in the YAML so nothing is
318
+ // silently lost. When every entry of an item is removed, the item's shell
319
+ // (the `- insert:` header and its header comment) goes too; a partially
320
+ // migrated item keeps its shell and the surviving entries.
321
+ const removedRanges = new Set();
322
+ for (const block of blocks) {
323
+ if (!block.config?.serverName) continue;
324
+ for (let index = block.start; index < block.start + block.entry.lines.length; index += 1) {
325
+ removedRanges.add(index);
326
+ }
327
+ }
328
+ // drop shells of fully-migrated items (leading comment lines included)
329
+ for (const item of items) {
330
+ if (!item.lines.some((line) => MCP_CLIENT_NAME.test(line))) continue;
331
+ const { entries } = splitItemEntries(item.lines);
332
+ const migrated = entries.filter((entry) =>
333
+ entry.lines.some((line) => MCP_CLIENT_NAME.test(line)) && blockToServerConfig(entry.lines)?.serverName,
334
+ );
335
+ if (migrated.length === entries.length) {
336
+ for (let index = item.start; index < item.start + item.lines.length; index += 1) {
337
+ removedRanges.add(index);
338
+ }
339
+ }
340
+ }
341
+ const kept = [];
342
+ for (let index = 0; index < lines.length; index += 1) {
343
+ if (removedRanges.has(index)) continue;
344
+ kept.push(lines[index]);
345
+ }
346
+ // trim: collapse the blank-line runs left by removals inside the document
347
+ const cleaned = kept.join("\n").replace(/\n{3,}/g, "\n\n");
348
+ writeFileSync(targetPath, cleaned.endsWith("\n") ? cleaned : cleaned + "\n", "utf8");
349
+
350
+ return { ingested, skipped, cleanedYaml: true };
351
+ }
352
+
353
+ function formatScalar(value) {
354
+ const text = String(value ?? "");
355
+ if (text === "") return "''";
356
+ const needsQuote =
357
+ /^[-?:,\[\]{}#&*!|>'"%@`\s]/.test(text) || // starts with a YAML indicator
358
+ /:\s/.test(text) || // colon-space ends a plain scalar
359
+ /\s#/.test(text) || // space-hash starts a comment
360
+ /\s$/.test(text) || // trailing space
361
+ /['"]/.test(text); // quote chars: escape conservatively
362
+ return needsQuote ? `'${text.replace(/'/g, "''")}'` : text;
363
+ }
364
+
365
+ /** Render one store server config as a cordis.patch.yml insert entry. */
366
+ export function formatMcpYamlEntry(name, config) {
367
+ const lines = [
368
+ `# dsh-mcp-${name}: managed by dsh-mcp-console`,
369
+ "- insert:",
370
+ ` - id: mcp-${name}`,
371
+ " name: '@deepseek-ai/dsh-mcp-client'",
372
+ " config:",
373
+ ` serverName: ${formatScalar(name)}`,
374
+ ` transport: ${config.transport}`,
375
+ ];
376
+ if (config.transport === "stdio") {
377
+ lines.push(` command: ${formatScalar(config.command ?? "")}`);
378
+ if (Array.isArray(config.args) && config.args.length > 0) {
379
+ lines.push(" args:");
380
+ for (const arg of config.args) lines.push(` - ${formatScalar(arg)}`);
381
+ }
382
+ if (config.env && Object.keys(config.env).length > 0) {
383
+ lines.push(" env:");
384
+ for (const [key, value] of Object.entries(config.env)) {
385
+ lines.push(` ${formatScalar(key)}: ${formatScalar(value)}`);
386
+ }
387
+ }
388
+ if (config.cwd) lines.push(` cwd: ${formatScalar(config.cwd)}`);
389
+ } else {
390
+ lines.push(` url: ${formatScalar(config.url ?? "")}`);
391
+ if (config.headers && Object.keys(config.headers).length > 0) {
392
+ lines.push(" headers:");
393
+ for (const [key, value] of Object.entries(config.headers)) {
394
+ lines.push(` ${formatScalar(key)}: ${formatScalar(value)}`);
395
+ }
396
+ }
397
+ }
398
+ lines.push(` toolCallTimeoutMs: ${config.toolCallTimeoutMs ?? 60000}`);
399
+ lines.push(` failOnStartupError: ${config.failOnStartupError === true}`);
400
+ return lines.join("\n");
401
+ }
402
+
403
+ /**
404
+ * Export: append every store server back into the profile patch YAML as
405
+ * standard mcp-client entries (uninstall/backup reversibility).
406
+ *
407
+ * @param {object} options
408
+ * @param {import("./store.js").Store} options.store
409
+ * @param {string} [options.yamlPath]
410
+ * @param {boolean} [options.remove] when true, previously exported entries
411
+ * (the marked block) are replaced instead of duplicated
412
+ */
413
+ export async function exportStoreToProfileYaml({ store, yamlPath }) {
414
+ const targetPath = yamlPath ?? defaultProfilePatchYamlPath();
415
+ const doc = store.read();
416
+ const servers = doc.servers ?? {};
417
+ const names = Object.keys(servers);
418
+ if (names.length === 0) {
419
+ return { exported: [], filePath: targetPath };
420
+ }
421
+
422
+ let baseContent = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
423
+
424
+ // drop a previous export block so re-exports do not duplicate
425
+ const markerStart = "# ---- Exported by dsh-mcp-console ----";
426
+ const lines = baseContent.split("\n");
427
+ const firstMarker = lines.findIndex((line) => line.trim() === markerStart);
428
+ if (firstMarker !== -1) {
429
+ lines.splice(firstMarker, lines.length - firstMarker);
430
+ baseContent = lines.join("\n").replace(/\n+$/, "");
431
+ }
432
+
433
+ const entries = names.map((name) => formatMcpYamlEntry(name, servers[name]));
434
+ const appendContent =
435
+ (baseContent.endsWith("\n") || baseContent === "" ? "" : "\n") +
436
+ `\n${markerStart}\n` +
437
+ entries.join("\n") +
438
+ "\n";
439
+ writeFileSync(targetPath, baseContent + appendContent, "utf8");
440
+ return { exported: names, filePath: targetPath };
441
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@moon16u/dsh-plugin-mcp-console",
3
+ "version": "0.1.0",
4
+ "description": "Web GUI console for MCP servers: manages the official @deepseek-ai/dsh-mcp-client at runtime — add/edit/enable/disable/reconnect/delete servers, live status, mcpServers JSON import. No MCP protocol code: connection and tool registration are 100% the official client, resolved from the running DSH profile.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./lib/types/client/index.d.ts",
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./package.json": "./package.json",
18
+ "./cordis.patch.yml": "./cordis.patch.yml"
19
+ },
20
+ "files": [
21
+ "lib",
22
+ "cordis.patch.yml"
23
+ ],
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-locale",
31
+ "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-ui-conversation",
33
+ "@deepseek-ai/dsh-client-ui-slots"
34
+ ],
35
+ "platform": "web"
36
+ }
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/cordis": "^4.0.1",
40
+ "@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
41
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
42
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@deepseek-ai/dsh-mcp-client": {
46
+ "optional": true
47
+ },
48
+ "@deepseek-ai/dsh-host-webserver": {
49
+ "optional": true
50
+ },
51
+ "@deepseek-ai/dsh-tools": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "engines": {
56
+ "node": ">=20.0.0"
57
+ },
58
+ "license": "MIT",
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "scripts": {
64
+ "test": "node --test test/*.test.mjs",
65
+ "sync-root-client": "node scripts/sync-root-client.mjs"
66
+ }
67
+ }