@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,214 @@
1
+ /**
2
+ * The ONLY module that touches `@deepseek-ai/dsh-mcp-client` (plan §7 R2:
3
+ * every official-package coupling converges here).
4
+ *
5
+ * Resolution is deliberately DYNAMIC, not a static import: the console
6
+ * package itself vendors nothing, and the official client is loaded from
7
+ * the running DSH profile's module graph so that the SAME module instance
8
+ * the loader used for manually-declared servers is reused. Sharing the
9
+ * instance matters: the official client keeps its serverName namespace
10
+ * reservation in module-level state, and one instance means conflicts with
11
+ * profile-declared entries are caught by the official mechanism itself, not
12
+ * only by our pre-check.
13
+ *
14
+ * Search order (profile first — see the module doc for why sharing the
15
+ * loader's instance matters):
16
+ * 1. `<DSH_HOME>/profiles/node_modules` — the profiles workspace hoisted
17
+ * root (plan §2.1 verified layout);
18
+ * 2. `<DSH_HOME>/profiles/<profile>/node_modules` for every profile;
19
+ * 3. plain specifier resolution from this package (standalone installs
20
+ * whose peer deps are locally resolvable).
21
+ */
22
+ import { createRequire } from "node:module";
23
+ import { existsSync, readdirSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+
28
+ /** serverName contract, mirrored from the official client's schema. */
29
+ export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
30
+
31
+ /** Official default for per-tool-call timeout (ms). */
32
+ export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60000;
33
+
34
+ /** Mask shown for secret-shaped values in API responses and the panel. */
35
+ export const MASK = "••••";
36
+
37
+ const CLIENT_SPECIFIER = "@deepseek-ai/dsh-mcp-client";
38
+
39
+ let cachedClient = undefined;
40
+
41
+ /** Candidate resolution bases for the official client, most specific first. */
42
+ export function resolutionBases(dshHome) {
43
+ const root = dshHome ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
44
+ const bases = [];
45
+ const profilesRoot = join(root, "profiles");
46
+ const hoisted = join(profilesRoot, "node_modules");
47
+ if (existsSync(hoisted)) bases.push(hoisted);
48
+ try {
49
+ for (const entry of readdirSync(profilesRoot, { withFileTypes: true })) {
50
+ if (!entry.isDirectory()) continue;
51
+ const base = join(profilesRoot, entry.name, "node_modules");
52
+ if (existsSync(base) && !bases.includes(base)) bases.push(base);
53
+ }
54
+ } catch {
55
+ // no profiles dir: the hoisted base (if any) plus self-resolution remain
56
+ }
57
+ return bases;
58
+ }
59
+
60
+ /**
61
+ * Resolve the official client plugin module ({ name, inject, Config, apply }).
62
+ * Memoized; failures are retried on the next call.
63
+ *
64
+ * @returns {Promise<typeof import("@deepseek-ai/dsh-mcp-client")>}
65
+ */
66
+ export async function resolveMcpClient() {
67
+ if (cachedClient !== undefined) return cachedClient;
68
+ const attempts = [];
69
+ for (const base of resolutionBases()) {
70
+ try {
71
+ const require = createRequire(join(base, "clientAdapter-anchor.js"));
72
+ const resolved = require.resolve(CLIENT_SPECIFIER);
73
+ if (typeof resolved !== "string" || !existsSync(resolved)) continue;
74
+ cachedClient = await import(pathToFileURL(resolved).href);
75
+ return cachedClient;
76
+ } catch (error) {
77
+ attempts.push(`${base}: ${String(error?.message ?? error)}`);
78
+ }
79
+ }
80
+ // fallback: self-context resolution (standalone installs with locally
81
+ // resolvable peer deps — resolves to the same instance there anyway)
82
+ try {
83
+ cachedClient = await import(CLIENT_SPECIFIER);
84
+ return cachedClient;
85
+ } catch (error) {
86
+ attempts.push(`self: ${String(error?.message ?? error)}`);
87
+ }
88
+ throw new Error(
89
+ `mcp-console: cannot resolve ${CLIENT_SPECIFIER} (tried ${resolutionBases().length} profile node_modules bases and self resolution: ${attempts.join(" | ")}). ` +
90
+ "Ensure dsh is installed and the web profile has been booted at least once.",
91
+ );
92
+ }
93
+
94
+ function num(value, fallback) {
95
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
96
+ }
97
+
98
+ function bool(value, fallback) {
99
+ return typeof value === "boolean" ? value : fallback;
100
+ }
101
+
102
+ function str(value) {
103
+ return typeof value === "string" ? value : "";
104
+ }
105
+
106
+ function strArray(value) {
107
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
108
+ }
109
+
110
+ function strMap(value) {
111
+ if (value === undefined || value === null) return {};
112
+ if (typeof value !== "object" || Array.isArray(value)) return {};
113
+ const out = {};
114
+ for (const [key, item] of Object.entries(value)) {
115
+ if (typeof item === "string") out[key] = item;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Validate one stored server config (console shape) before it is persisted or
122
+ * assembled. Returns `{ config }` normalized, or `{ error }` explaining the
123
+ * first problem.
124
+ *
125
+ * @param {object} server stored config (may lack defaults)
126
+ * @returns {{ config?: object, error?: string }}
127
+ */
128
+ export function validateServerConfig(server) {
129
+ if (server === null || typeof server !== "object") return { error: "server config must be an object" };
130
+ const name = str(server.name).trim();
131
+ if (!SERVER_NAME_PATTERN.test(name)) {
132
+ return { error: `serverName "${name}" must match [A-Za-z0-9_-]{1,32}` };
133
+ }
134
+ const transport = server.transport;
135
+ if (transport !== "stdio" && transport !== "streamable-http") {
136
+ return { error: `transport must be "stdio" or "streamable-http" (got ${JSON.stringify(transport)})` };
137
+ }
138
+ const config = {
139
+ name,
140
+ enabled: bool(server.enabled, true),
141
+ transport,
142
+ scope: server.scope === "project" ? "project" : "global",
143
+ toolCallTimeoutMs: num(server.toolCallTimeoutMs, DEFAULT_TOOL_CALL_TIMEOUT_MS),
144
+ failOnStartupError: bool(server.failOnStartupError, false),
145
+ /** Disabled public tool names, per-tool switch (mcp-manager-gui-spec §3.4). */
146
+ disabledTools: Array.isArray(server.disabledTools)
147
+ ? server.disabledTools.filter((item) => typeof item === "string")
148
+ : [],
149
+ };
150
+ if (transport === "stdio") {
151
+ const command = str(server.command).trim();
152
+ if (command.length === 0) return { error: "stdio transport requires a command" };
153
+ config.command = command;
154
+ config.args = strArray(server.args);
155
+ config.env = strMap(server.env);
156
+ config.cwd = str(server.cwd);
157
+ } else {
158
+ const url = str(server.url).trim();
159
+ if (!/^https?:\/\//i.test(url)) {
160
+ return { error: "streamable-http transport requires an http(s) url" };
161
+ }
162
+ config.url = url;
163
+ config.headers = strMap(server.headers);
164
+ }
165
+ if (server.reconnect !== undefined && server.reconnect !== null) {
166
+ if (typeof server.reconnect !== "object" || Array.isArray(server.reconnect)) {
167
+ return { error: "reconnect must be an object" };
168
+ }
169
+ config.reconnect = { ...server.reconnect };
170
+ }
171
+ return { config };
172
+ }
173
+
174
+ /**
175
+ * Translate one stored console config into the official client's config
176
+ * (input to `ctx.plugin(mcpClient, config)`). Assumes validateServerConfig()
177
+ * has passed; still coerces defensively.
178
+ *
179
+ * @param {object} server validated console config
180
+ * @returns {object} official StdioConfig | StreamableHttpConfig
181
+ */
182
+ export function toClientConfig(server) {
183
+ const config = {
184
+ transport: server.transport,
185
+ serverName: server.name,
186
+ toolCallTimeoutMs: num(server.toolCallTimeoutMs, DEFAULT_TOOL_CALL_TIMEOUT_MS),
187
+ failOnStartupError: bool(server.failOnStartupError, false),
188
+ };
189
+ if (server.transport === "stdio") {
190
+ config.command = str(server.command);
191
+ config.args = strArray(server.args);
192
+ config.env = strMap(server.env);
193
+ config.cwd = str(server.cwd);
194
+ } else {
195
+ config.url = str(server.url);
196
+ config.headers = strMap(server.headers);
197
+ }
198
+ if (server.reconnect && typeof server.reconnect === "object") {
199
+ config.reconnect = { ...server.reconnect };
200
+ }
201
+ return config;
202
+ }
203
+
204
+ /**
205
+ * Mask secret-shaped values for API responses and the panel. env/headers
206
+ * values become MASK when set; keys are kept so the shape stays visible.
207
+ */
208
+ export function maskRecord(record) {
209
+ const out = {};
210
+ for (const [key, value] of Object.entries(record ?? {})) {
211
+ out[key] = typeof value === "string" && value.length > 0 ? MASK : value;
212
+ }
213
+ return out;
214
+ }
package/lib/import.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * mcpServers JSON import (plan §5.5).
3
+ *
4
+ * Accepts the Claude Code / Codex family of shapes:
5
+ *
6
+ * { "mcpServers": { "<name>": { "command": "...", "args": [...], "env": {...} } } }
7
+ * { "mcpServers": { "<name>": { "url": "https://...", "headers": {...}, "type": "http" } } }
8
+ *
9
+ * plus the bare map without the `mcpServers` wrapper. stdio entries map
10
+ * 1:1; http entries are normalized to the official client's
11
+ * `streamable-http` transport, tolerating the common key variants
12
+ * (`url`/`serverUrl`/`endpoint`, `type: "http"|"streamable-http"|"sse"`).
13
+ * Names are reported, never overwritten — conflicts are the caller's to
14
+ * surface (the orchestrator skips and reports them).
15
+ */
16
+
17
+ const NAME_LIMIT = 100;
18
+
19
+ /**
20
+ * Parse pasted JSON into console server configs.
21
+ * @param {string} text
22
+ * @returns {{ servers: {name: string, config: object}[], errors: string[] }}
23
+ */
24
+ export function parseMcpServers(text) {
25
+ const errors = [];
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(text);
29
+ } catch (error) {
30
+ return { servers: [], errors: [`invalid JSON: ${String(error?.message ?? error)}`] };
31
+ }
32
+ let map = parsed;
33
+ if (isPlainObject(parsed) && isPlainObject(parsed.mcpServers)) {
34
+ map = parsed.mcpServers;
35
+ } else if (isPlainObject(parsed) && isPlainObject(parsed.servers) && !parsed.command && !parsed.url) {
36
+ map = parsed.servers;
37
+ }
38
+ if (!isPlainObject(map)) {
39
+ return { servers: [], errors: ["expected an object like { mcpServers: { name: { command, args, env } } }"] };
40
+ }
41
+ const servers = [];
42
+ for (const [name, entry] of Object.entries(map)) {
43
+ if (servers.length >= NAME_LIMIT) {
44
+ errors.push(`import capped at ${NAME_LIMIT} servers; remaining entries ignored`);
45
+ break;
46
+ }
47
+ if (!isPlainObject(entry)) {
48
+ errors.push(`"${name}": not an object, skipped`);
49
+ continue;
50
+ }
51
+ const config = mapEntry(name, entry);
52
+ if (config) servers.push({ name, config });
53
+ else errors.push(`"${name}": neither a stdio (command/args/env) nor an http (url/headers) entry, skipped`);
54
+ }
55
+ return { servers, errors };
56
+ }
57
+
58
+ function mapEntry(name, entry) {
59
+ const common = {
60
+ enabled: true,
61
+ scope: "global",
62
+ toolCallTimeoutMs: typeof entry.toolCallTimeoutMs === "number" ? entry.toolCallTimeoutMs : undefined,
63
+ failOnStartupError: entry.failOnStartupError === true,
64
+ };
65
+ const url = firstString(entry.url, entry.serverUrl, entry.endpoint);
66
+ if (url) {
67
+ return {
68
+ ...common,
69
+ transport: "streamable-http",
70
+ url,
71
+ headers: stringMap(entry.headers),
72
+ };
73
+ }
74
+ if (typeof entry.command === "string" && entry.command.length > 0) {
75
+ return {
76
+ ...common,
77
+ transport: "stdio",
78
+ command: entry.command,
79
+ args: Array.isArray(entry.args) ? entry.args.filter((item) => typeof item === "string") : [],
80
+ env: stringMap(entry.env),
81
+ cwd: typeof entry.cwd === "string" ? entry.cwd : "",
82
+ };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function firstString(...values) {
88
+ for (const value of values) {
89
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function stringMap(value) {
95
+ if (!isPlainObject(value)) return {};
96
+ const out = {};
97
+ for (const [key, item] of Object.entries(value)) {
98
+ if (typeof item === "string") out[key] = item;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function isPlainObject(value) {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value);
105
+ }
package/lib/index.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Host-side entry for @moon16u/dsh-plugin-mcp-console.
3
+ *
4
+ * A thin composition root (plan §4): Store (persistence) + Orchestrator
5
+ * (dynamic assembly of the official `@deepseek-ai/dsh-mcp-client`) +
6
+ * Status (observation) + Routes (loopback HTTP API + SSE).
7
+ *
8
+ * The MCP protocol itself is never implemented here — every connection, tool
9
+ * registration and reconnect belongs to the official client plugin, loaded
10
+ * one fiber per server through `ctx.plugin()`.
11
+ *
12
+ * Service wiring uses `ctx.inject(["webServer", "tools"], ...)` rather than a
13
+ * static `inject` export: on a host without the web profile (no webServer)
14
+ * the plugin stays dormant instead of blocking the whole dsh-pouch bundle.
15
+ */
16
+ import { Store, globalStorePath, projectStorePath } from "./store.js";
17
+ import { Orchestrator } from "./orchestrator.js";
18
+ import { computeSnapshot } from "./status.js";
19
+ import { registerRoutes } from "./routes.js";
20
+ import { homedir } from "node:os";
21
+ import { resolve, parse, join } from "node:path";
22
+ import { existsSync, readFileSync } from "node:fs";
23
+
24
+ /** Cordis plugin name used by loader diagnostics. */
25
+ export const name = "mcp-console";
26
+
27
+ export function apply(ctx) {
28
+ ctx.inject(["webServer", "tools"], (scope) => {
29
+ const globalStore = new Store(globalStorePath());
30
+ const orchestrator = new Orchestrator(scope, { globalStore, onChange: () => notify() });
31
+
32
+ // 项目工作区自动感知:
33
+ // 1. 优先排除法 (process.cwd() 不是家目录或根目录)
34
+ // 2. 降级读取 DSH 本身存储的当前活跃工作区 (~/.dsh/storages/workspace.json)
35
+ try {
36
+ let activeWorkspace = null;
37
+ const cwd = process.cwd();
38
+ const home = homedir();
39
+ const isHomeOrRoot = !cwd || resolve(cwd) === resolve(home) || resolve(cwd) === parse(cwd).root;
40
+ if (!isHomeOrRoot && existsSync(cwd)) {
41
+ activeWorkspace = cwd;
42
+ } else {
43
+ const wsStoragePath = join(home, ".dsh", "storages", "workspace.json");
44
+ if (existsSync(wsStoragePath)) {
45
+ const raw = JSON.parse(readFileSync(wsStoragePath, "utf8"));
46
+ const workspaces = raw?.tables?.workspaces;
47
+ if (workspaces && typeof workspaces === "object") {
48
+ const list = Object.values(workspaces);
49
+ list.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
50
+ const target = list.find((w) => w.path && existsSync(w.path) && resolve(w.path) !== resolve(home));
51
+ if (target) activeWorkspace = target.path;
52
+ }
53
+ }
54
+ }
55
+
56
+ if (activeWorkspace) {
57
+ const projStore = new Store(projectStorePath(activeWorkspace));
58
+ orchestrator.setProjectStore(projStore, activeWorkspace);
59
+ }
60
+ } catch {
61
+ // 容错降级:保持 global-only
62
+ }
63
+
64
+ const routes = registerRoutes(scope, { orchestrator, uiStore: globalStore });
65
+ const notify = () => routes.notify();
66
+
67
+ // Per-tool disable (mcp-manager-gui-spec §3.4): a plain-context guard
68
+ // applies globally — the official dsh-tools contract for masking a tool
69
+ // for every agent. Disposed automatically with this scope.
70
+ scope.tools.guard((execution) => orchestrator.toolGuard(execution));
71
+
72
+ // Status change sources (plan §5.3): tool registrations anywhere,
73
+ // lifecycle transitions of OUR fibers, and a fallback timer while any
74
+ // managed server is mid-connect (zero tools inside the grace window).
75
+ scope.on("tools/change", () => notify());
76
+ scope.on("internal/status", (fiber) => {
77
+ for (const entry of orchestrator.ledger.values()) {
78
+ if (entry.fiber === fiber) {
79
+ notify();
80
+ return;
81
+ }
82
+ }
83
+ });
84
+
85
+ // Fallback poll (5s): bridges status transitions that emit no events —
86
+ // chiefly "connecting -> failed" when a server never produces tools.
87
+ // notify() dedups identical snapshots, so the poll is silent when idle.
88
+ let settleTimer = null;
89
+ settleTimer = setInterval(() => notify(), 5000);
90
+ if (typeof settleTimer.unref === "function") settleTimer.unref();
91
+
92
+ scope.effect(() => {
93
+ void orchestrator.bootstrap().then(() => notify());
94
+ return () => {
95
+ clearInterval(settleTimer);
96
+ routes.dispose();
97
+ };
98
+ }, "mcp-console: composition");
99
+ });
100
+ }
101
+
102
+ export { Store, globalStorePath, projectStorePath } from "./store.js";
103
+ export { Orchestrator, ConsoleError } from "./orchestrator.js";
104
+ export { computeSnapshot } from "./status.js";
105
+ export { parseMcpServers } from "./import.js";
106
+ export { registerRoutes, ROUTE_PREFIX, sanitizeUi } from "./routes.js";
107
+ export { validateServerConfig, toClientConfig } from "./clientAdapter.js";
108
+ export {
109
+ ingestProfileMcpEntries,
110
+ exportStoreToProfileYaml,
111
+ formatMcpYamlEntry,
112
+ defaultProfilePatchYamlPath,
113
+ } from "./yamlMigrator.js";