@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +954 -0
- package/lib/clientAdapter.js +214 -0
- package/lib/import.js +105 -0
- package/lib/index.js +113 -0
- package/lib/orchestrator.js +636 -0
- package/lib/routes.js +317 -0
- package/lib/status.js +126 -0
- package/lib/store.js +136 -0
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/index.d.ts +129 -0
- package/lib/yamlMigrator.js +441 -0
- package/package.json +67 -0
|
@@ -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
|
+
}
|