@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/lib/routes.js ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * HTTP API (plan §5.4): everything under `/api/dsh-mcp-console/`, registered
3
+ * as one prefix route on the web profile's `webServer` service.
4
+ *
5
+ * | Method | Path | Semantics |
6
+ * |--------|-----------------------------|------------------------------------|
7
+ * | GET | /health | liveness + store paths |
8
+ * | GET | /servers | full snapshot (secrets masked) |
9
+ * | POST | /servers | add (body = server config) |
10
+ * | PATCH | /servers/:name | edit (masked secrets round-trip) |
11
+ * | DELETE | /servers/:name | remove |
12
+ * | POST | /servers/:name/enable | enable + assemble |
13
+ * | POST | /servers/:name/disable | dispose (config kept) |
14
+ * | POST | /servers/:name/reconnect | fiber.restart() |
15
+ * | POST | /import | mcpServers JSON import |
16
+ * | GET | /events | SSE status stream |
17
+ * | GET | /config | UI config (non-secret) |
18
+ * | PUT | /config | persist UI config |
19
+ *
20
+ * Loopback fence: every mutating and secret-bearing route answers 403 to
21
+ * non-loopback callers; GET /config (UI placement only) is open so a LAN
22
+ * browser can at least read placement while the data routes stay local.
23
+ */
24
+ import { computeSnapshot } from "./status.js";
25
+ import { ConsoleError } from "./orchestrator.js";
26
+ import { parseMcpServers } from "./import.js";
27
+ import { exportStoreToProfileYaml } from "./yamlMigrator.js";
28
+
29
+ export const ROUTE_PREFIX = "/api/dsh-mcp-console";
30
+
31
+ /** Max accepted JSON body (mcpServers imports can be chunky but not huge). */
32
+ const BODY_LIMIT = 1024 * 1024;
33
+
34
+ const STATUS_BY_CODE = { invalid: 400, conflict: 409, not_found: 404, internal: 500 };
35
+
36
+ const LOOPBACK_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
37
+
38
+ /**
39
+ * Register the API on the webserver.
40
+ *
41
+ * @param {import("@deepseek-ai/cordis").Context} ctx
42
+ * @param {object} services
43
+ * @param {import("./orchestrator.js").Orchestrator} services.orchestrator
44
+ * @param {import("./store.js").Store} services.uiStore store holding the `ui` section
45
+ * @returns {{ dispose: () => void, notify: () => void }} route handle
46
+ */
47
+ export function registerRoutes(ctx, { orchestrator, uiStore }) {
48
+ const sseClients = new Set();
49
+ let heartbeat = null;
50
+ let notifyTimer = null;
51
+ let dirty = false;
52
+
53
+ const disposeRoute = ctx.webServer.register({
54
+ kind: "prefix",
55
+ path: ROUTE_PREFIX,
56
+ handler: (req, res) => {
57
+ handle(req, res).catch((error) => {
58
+ if (res.headersSent) {
59
+ res.end();
60
+ return;
61
+ }
62
+ const code = error instanceof ConsoleError ? error.code : "internal";
63
+ respond(res, STATUS_BY_CODE[code] ?? 500, { error: { code, message: String(error?.message ?? error) } });
64
+ });
65
+ },
66
+ });
67
+
68
+ heartbeat = setInterval(() => {
69
+ for (const client of sseClients) {
70
+ try {
71
+ client(": ping\n\n");
72
+ } catch {
73
+ // broken pipe: close handler will reap the client
74
+ }
75
+ }
76
+ }, 25000);
77
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
78
+
79
+ /**
80
+ * Broadcast a status_changed SSE frame (throttled 500ms, plan §5.3).
81
+ * Frames carry the full snapshot; identical consecutive snapshots are
82
+ * skipped, so polling and event storms never spam open panels.
83
+ */
84
+ let lastSent = null;
85
+ function notify() {
86
+ dirty = true;
87
+ if (notifyTimer) return;
88
+ notifyTimer = setTimeout(() => {
89
+ notifyTimer = null;
90
+ if (!dirty) return;
91
+ dirty = false;
92
+ const snapshot = JSON.stringify(computeSnapshot(orchestrator));
93
+ if (snapshot === lastSent) return;
94
+ lastSent = snapshot;
95
+ const frame = `event: status_changed\ndata: ${snapshot}\n\n`;
96
+ for (const client of sseClients) {
97
+ try {
98
+ client(frame);
99
+ } catch {
100
+ // reaped by close handler
101
+ }
102
+ }
103
+ }, 500);
104
+ if (typeof notifyTimer.unref === "function") notifyTimer.unref();
105
+ }
106
+
107
+ async function handle(req, res) {
108
+ const url = new URL(req.url ?? "/", "http://localhost");
109
+ const segments = url.pathname.slice(ROUTE_PREFIX.length).split("/").filter(Boolean).map(decodeURIComponent);
110
+ const method = req.method ?? "GET";
111
+
112
+ // loopback fence: everything except GET /config
113
+ const isConfigRead = method === "GET" && segments.length === 1 && segments[0] === "config";
114
+ if (!isConfigRead && !isLoopback(req)) {
115
+ respond(res, 403, { error: { code: "forbidden", message: "loopback only" } });
116
+ return;
117
+ }
118
+
119
+ if (segments[0] === "health" && method === "GET") {
120
+ respond(res, 200, {
121
+ ok: true,
122
+ plugin: "mcp-console",
123
+ globalStore: orchestrator.globalStore.path,
124
+ projectStore: orchestrator.projectStore?.path ?? null,
125
+ managed: [...orchestrator.ledger.keys()].sort(),
126
+ disabledTools: [...orchestrator.disabledToolSet].sort(),
127
+ profileYaml: orchestrator.profileYamlPath,
128
+ ingestError: orchestrator.ingestError,
129
+ });
130
+ return;
131
+ }
132
+
133
+ if (segments[0] === "refresh" && method === "POST") {
134
+ respond(res, 200, await orchestrator.refresh());
135
+ return;
136
+ }
137
+
138
+ if (segments[0] === "export-yaml" && method === "POST") {
139
+ const result = await exportStoreToProfileYaml({ store: orchestrator.globalStore });
140
+ respond(res, 200, result);
141
+ return;
142
+ }
143
+
144
+ if (segments[0] === "servers") {
145
+ await handleServers(req, res, method, segments.slice(1));
146
+ return;
147
+ }
148
+
149
+ if (segments[0] === "import" && method === "POST") {
150
+ const body = await readJson(req);
151
+ const text = typeof body?.json === "string" ? body.json : typeof body?.text === "string" ? body.text : "";
152
+ if (text.length === 0) {
153
+ respond(res, 400, { error: { code: "invalid", message: "body must be { json: \"<mcpServers JSON>\" }" } });
154
+ return;
155
+ }
156
+ const { servers, errors } = parseMcpServers(text);
157
+ const result = await orchestrator.importServers(servers);
158
+ respond(res, 200, { ...result, parseErrors: errors });
159
+ return;
160
+ }
161
+
162
+ if (segments[0] === "events" && method === "GET") {
163
+ handleSse(req, res);
164
+ return;
165
+ }
166
+
167
+ if (segments[0] === "config") {
168
+ if (method === "GET") {
169
+ respond(res, 200, { ui: sanitizeUi(uiStore.read().ui) });
170
+ return;
171
+ }
172
+ if (method === "PUT" || method === "POST") {
173
+ const body = await readJson(req);
174
+ respond(res, 200, { ui: sanitizeUi(uiStore.writeUi(sanitizeUi(body?.ui ?? {}))) });
175
+ return;
176
+ }
177
+ respond(res, 405, { error: { code: "method_not_allowed", message: "GET or PUT /config" } });
178
+ return;
179
+ }
180
+
181
+ respond(res, 404, { error: { code: "not_found", message: `unknown route ${method} ${url.pathname}` } });
182
+ }
183
+
184
+ async function handleServers(req, res, method, rest) {
185
+ if (rest.length === 0) {
186
+ if (method === "GET") {
187
+ respond(res, 200, computeSnapshot(orchestrator));
188
+ return;
189
+ }
190
+ if (method === "POST") {
191
+ const body = await readJson(req);
192
+ const result = await orchestrator.add(body ?? {});
193
+ respond(res, 201, result);
194
+ return;
195
+ }
196
+ respond(res, 405, { error: { code: "method_not_allowed", message: "GET or POST /servers" } });
197
+ return;
198
+ }
199
+
200
+ const [name, action] = rest;
201
+ if (action === undefined) {
202
+ if (method === "PATCH") {
203
+ const body = await readJson(req);
204
+ respond(res, 200, await orchestrator.update(name, body ?? {}));
205
+ return;
206
+ }
207
+ if (method === "DELETE") {
208
+ respond(res, 200, await orchestrator.remove(name));
209
+ return;
210
+ }
211
+ respond(res, 405, { error: { code: "method_not_allowed", message: "PATCH or DELETE /servers/:name" } });
212
+ return;
213
+ }
214
+
215
+ if (method !== "POST") {
216
+ respond(res, 405, { error: { code: "method_not_allowed", message: `POST /servers/:name/${action}` } });
217
+ return;
218
+ }
219
+ if (action === "enable") respond(res, 200, await orchestrator.setEnabled(name, true));
220
+ else if (action === "disable") respond(res, 200, await orchestrator.setEnabled(name, false));
221
+ else if (action === "reconnect") respond(res, 200, await orchestrator.reconnect(name));
222
+ else respond(res, 404, { error: { code: "not_found", message: `unknown action "${action}"` } });
223
+ }
224
+
225
+ function handleSse(req, res) {
226
+ res.writeHead(200, {
227
+ "content-type": "text/event-stream; charset=utf-8",
228
+ "cache-control": "no-cache, no-transform",
229
+ connection: "keep-alive",
230
+ "x-accel-buffering": "no",
231
+ });
232
+ res.write("retry: 3000\n\n");
233
+ const send = (frame) => res.write(frame);
234
+ sseClients.add(send);
235
+ // initial snapshot so a freshly opened panel renders immediately
236
+ send(`event: status_changed\ndata: ${JSON.stringify(computeSnapshot(orchestrator))}\n\n`);
237
+ req.on("close", () => {
238
+ sseClients.delete(send);
239
+ });
240
+ }
241
+
242
+ function dispose() {
243
+ disposeRoute();
244
+ if (heartbeat) clearInterval(heartbeat);
245
+ if (notifyTimer) clearTimeout(notifyTimer);
246
+ for (const client of sseClients) {
247
+ try {
248
+ client("event: bye\ndata: {}\n\n");
249
+ } catch {
250
+ // already gone
251
+ }
252
+ }
253
+ sseClients.clear();
254
+ }
255
+
256
+ return { dispose, notify };
257
+ }
258
+
259
+ function isLoopback(req) {
260
+ const address = req?.socket?.remoteAddress;
261
+ return typeof address === "string" && LOOPBACK_ADDRESSES.has(address);
262
+ }
263
+
264
+ function respond(res, status, body) {
265
+ const payload = JSON.stringify(body);
266
+ res.writeHead(status, {
267
+ "content-type": "application/json; charset=utf-8",
268
+ "cache-control": "no-store",
269
+ });
270
+ res.end(payload);
271
+ }
272
+
273
+ function readJson(req) {
274
+ return new Promise((resolve, reject) => {
275
+ const chunks = [];
276
+ let size = 0;
277
+ req.on("data", (chunk) => {
278
+ size += chunk.length;
279
+ if (size > BODY_LIMIT) {
280
+ reject(new ConsoleError("invalid", `body exceeds ${BODY_LIMIT} bytes`));
281
+ req.destroy();
282
+ return;
283
+ }
284
+ chunks.push(chunk);
285
+ });
286
+ req.on("end", () => {
287
+ if (chunks.length === 0) {
288
+ resolve(undefined);
289
+ return;
290
+ }
291
+ try {
292
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
293
+ } catch (error) {
294
+ reject(new ConsoleError("invalid", `invalid JSON body: ${String(error?.message ?? error)}`));
295
+ }
296
+ });
297
+ req.on("error", reject);
298
+ });
299
+ }
300
+
301
+ const UI_POSITIONS = new Set(["top-right", "bottom-right"]);
302
+
303
+ /** Only known, non-secret UI keys survive into the store. */
304
+ export function sanitizeUi(ui) {
305
+ const out = { position: "top-right", offsetX: 8, offsetY: 8 };
306
+ if (ui === null || typeof ui !== "object") return out;
307
+ if (UI_POSITIONS.has(ui.position)) out.position = ui.position;
308
+ out.offsetX = clampInt(ui.offsetX, 0, 256, out.offsetX);
309
+ out.offsetY = clampInt(ui.offsetY, 0, 256, out.offsetY);
310
+ return out;
311
+ }
312
+
313
+ function clampInt(value, min, max, fallback) {
314
+ const n = Number(value);
315
+ if (!Number.isInteger(n) || n < min || n > max) return fallback;
316
+ return n;
317
+ }
package/lib/status.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Status observation and aggregation (plan §5.3, MVP口径).
3
+ *
4
+ * State machine per server: `disabled` (configured, not enabled) → `loading`
5
+ * (fiber starting) → `running` (fiber live AND tools registered) /
6
+ * `connecting` (fiber live, zero tools, inside the grace window) → `failed`
7
+ * (fiber FAILED, startup error, or zero tools after the grace window).
8
+ *
9
+ * Honest MVP caveats (documented in README known-limitations):
10
+ * - "connecting" vs "reconnecting, attempt N" cannot be distinguished — the
11
+ * official client does not expose structured connection-level events yet.
12
+ * - A connected server that legitimately exposes zero tools reads as
13
+ * "failed" after the grace window.
14
+ *
15
+ * Change sources: the global `tools/change` event (tool registration or
16
+ * unregistration anywhere), fiber `internal/status` transitions for fibers
17
+ * in our ledger, and a fallback timer while anything is mid-connect.
18
+ */
19
+ import { FiberState, CONNECT_GRACE_MS } from "./orchestrator.js";
20
+
21
+ /**
22
+ * Derive one server's status string from its ledger entry and live tools.
23
+ * @param {object|undefined} entry ledger entry (undefined = not assembled)
24
+ * @param {string[]} tools live tool names attributed to the server
25
+ * @param {boolean} enabled whether the stored config has enabled !== false
26
+ */
27
+ export function serverStatus(entry, tools, enabled) {
28
+ if (!enabled) return "disabled";
29
+ if (!entry) return "failed";
30
+ if (entry.pending) return "loading";
31
+ if (entry.error) return "failed";
32
+ const state = entry.fiber ? entry.fiber.state : FiberState.DISPOSED;
33
+ if (state === FiberState.FAILED) return "failed";
34
+ if (state === FiberState.PENDING || state === FiberState.LOADING || state === FiberState.UNLOADING) {
35
+ return "loading";
36
+ }
37
+ if (tools.length > 0) return "running";
38
+ if (state === FiberState.ACTIVE) {
39
+ return Date.now() - entry.loadedAt < CONNECT_GRACE_MS ? "connecting" : "failed";
40
+ }
41
+ return "failed";
42
+ }
43
+
44
+ /**
45
+ * Full API snapshot: every stored server with live status, masked secrets,
46
+ * tool lists, plus profile-declared (external) servers as read-only entries
47
+ * with their live tool lists (mcp-manager-v1-review §2-4: read-only servers
48
+ * render inline in the list with a badge, no delete, controls disabled).
49
+ *
50
+ * @param {import("./orchestrator.js").Orchestrator} orchestrator
51
+ */
52
+ export function computeSnapshot(orchestrator) {
53
+ const stored = orchestrator.storedServers();
54
+ const currentTools = orchestrator.toolNamesSnapshot();
55
+ const servers = [];
56
+ for (const [name, config] of stored) {
57
+ const entry = orchestrator.ledger.get(name);
58
+ const liveTools = orchestrator.toolsOf(name, currentTools);
59
+ const disabled = new Set(Array.isArray(config.disabledTools) ? config.disabledTools : []);
60
+ const prefix = `mcp__${name}__`;
61
+ const enabled = config.enabled !== false;
62
+ const tools = liveTools.map((publicName) => ({
63
+ name: publicName,
64
+ label: publicName.startsWith(prefix) ? publicName.slice(prefix.length) : publicName,
65
+ enabled: !disabled.has(publicName),
66
+ }));
67
+ const status = serverStatus(entry, liveTools, enabled);
68
+ const view = orchestrator.publicConfig(config);
69
+ servers.push({
70
+ ...view,
71
+ status,
72
+ toolCount: liveTools.length,
73
+ enabledToolCount: enabled ? tools.filter((tool) => tool.enabled).length : 0,
74
+ tools,
75
+ error: entry?.error ?? null,
76
+ });
77
+ }
78
+ servers.sort((a, b) => a.name.localeCompare(b.name));
79
+
80
+ const externalServers = [];
81
+ for (const name of orchestrator.externalServerNames()) {
82
+ const prefix = `mcp__${name}__`;
83
+ const tools = [...currentTools]
84
+ .filter((toolName) => toolName.startsWith(prefix))
85
+ .sort()
86
+ .map((publicName) => ({
87
+ name: publicName,
88
+ label: publicName.startsWith(prefix) ? publicName.slice(prefix.length) : publicName,
89
+ enabled: true,
90
+ }));
91
+ externalServers.push({
92
+ name,
93
+ readOnly: true,
94
+ enabled: true,
95
+ status: tools.length > 0 ? "running" : "connecting",
96
+ toolCount: tools.length,
97
+ enabledToolCount: tools.length,
98
+ tools,
99
+ error: null,
100
+ });
101
+ }
102
+ externalServers.sort((a, b) => a.name.localeCompare(b.name));
103
+
104
+ return {
105
+ version: 1,
106
+ servers,
107
+ externalServers,
108
+ hasProjectWorkspace: Boolean(orchestrator.projectStore),
109
+ projectWorkspaceRoot: orchestrator.projectRoot ?? null,
110
+ storeErrors: [
111
+ orchestrator.globalStore.lastError,
112
+ orchestrator.projectStore?.lastError ?? null,
113
+ orchestrator.ingestError,
114
+ ].filter(Boolean),
115
+ clientError: orchestrator.clientError ?? null,
116
+ };
117
+ }
118
+
119
+ /** Group servers by status for the panel's sectioned list. */
120
+ export function groupByStatus(servers) {
121
+ const groups = { running: [], connecting: [], loading: [], disabled: [], failed: [] };
122
+ for (const server of servers) {
123
+ (groups[server.status] ?? groups.failed).push(server);
124
+ }
125
+ return groups;
126
+ }
package/lib/store.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Configuration persistence for the MCP console.
3
+ *
4
+ * One JSON document per scope, each shaped `{ version, servers, ui? }` where
5
+ * `servers` maps `serverName -> ServerConfig` (the console's own storage
6
+ * format — NOT the official client's config shape; see clientAdapter.js for
7
+ * the translation). Writes are atomic (temp file + rename) and every write
8
+ * first snapshots the previous file to `<file>.bak.<ts>` so deletions stay
9
+ * recoverable. A corrupt file is renamed aside (`.corrupt.<ts>`) and rebuilt
10
+ * empty — the corruption is surfaced through `lastError`, never silently
11
+ * dropped.
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join, dirname } from "node:path";
16
+ import { randomBytes } from "node:crypto";
17
+
18
+ /** Current document schema version. */
19
+ export const STORE_VERSION = 1;
20
+
21
+ /** serverName contract, mirrored from the official mcp-client. */
22
+ export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
23
+
24
+ /** Global-scope store path: `$DSH_HOME/dsh-mcp.json` (defaults to ~/.dsh). */
25
+ export function globalStorePath(dshHome) {
26
+ const root = dshHome ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
27
+ return join(root, "dsh-mcp.json");
28
+ }
29
+
30
+ /** Project-scope store path: `<workspace>/.dsh/mcp.json`. */
31
+ export function projectStorePath(workspaceRoot) {
32
+ return join(workspaceRoot, ".dsh", "mcp.json");
33
+ }
34
+
35
+ function isPlainObject(value) {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function timestamp() {
40
+ return new Date().toISOString().replace(/[:.]/g, "-");
41
+ }
42
+
43
+ export function emptyDoc() {
44
+ return { version: STORE_VERSION, servers: {} };
45
+ }
46
+
47
+ /** Normalize one parsed document; returns `{ doc, error }`. */
48
+ export function normalizeDoc(raw) {
49
+ if (raw === undefined || raw === null) return { doc: emptyDoc(), error: null };
50
+ if (!isPlainObject(raw)) return { doc: emptyDoc(), error: "store root must be a JSON object" };
51
+ const doc = {
52
+ version: typeof raw.version === "number" ? raw.version : STORE_VERSION,
53
+ servers: {},
54
+ ...(isPlainObject(raw.ui) ? { ui: raw.ui } : {}),
55
+ };
56
+ if (isPlainObject(raw.servers)) {
57
+ for (const [name, entry] of Object.entries(raw.servers)) {
58
+ if (isPlainObject(entry)) doc.servers[name] = entry;
59
+ }
60
+ }
61
+ const error = isPlainObject(raw.servers) ? null : "`servers` missing or not an object; rebuilt empty";
62
+ return { doc, error };
63
+ }
64
+
65
+ /**
66
+ * One persisted document with atomic writes (temp file + rename).
67
+ *
68
+ * Deletions are the user's explicit intent, so no backup files are kept:
69
+ * every write replaces the previous file directly. `read()` re-reads from
70
+ * disk, so external edits (a user fixing a config by hand) are not clobbered.
71
+ */
72
+ export class Store {
73
+ /**
74
+ * @param {string} filePath absolute path of the JSON document
75
+ */
76
+ constructor(filePath) {
77
+ this.path = filePath;
78
+ /** Last read/write failure, surfaced in the panel (never silently dropped). */
79
+ this.lastError = null;
80
+ }
81
+
82
+ /** Read the document from disk; corrupt files are moved aside and rebuilt. */
83
+ read() {
84
+ let raw;
85
+ if (existsSync(this.path)) {
86
+ try {
87
+ raw = JSON.parse(readFileSync(this.path, "utf8"));
88
+ } catch (error) {
89
+ const aside = `${this.path}.corrupt.${timestamp()}`;
90
+ try {
91
+ renameSync(this.path, aside);
92
+ this.lastError = `config file was corrupt; moved to ${aside} and rebuilt empty (${String(error?.message ?? error)})`;
93
+ } catch (renameError) {
94
+ this.lastError = `config file is corrupt and could not be moved aside (${String(renameError?.message ?? renameError)})`;
95
+ }
96
+ raw = undefined;
97
+ }
98
+ }
99
+ const { doc, error } = normalizeDoc(raw);
100
+ if (error && !this.lastError) this.lastError = error;
101
+ return doc;
102
+ }
103
+
104
+ /**
105
+ * Mutate the servers map through `mutate(servers)` and persist atomically.
106
+ * `mutate` returning `false` cancels the write (validation failed).
107
+ * @returns the mutate() result
108
+ */
109
+ mutate(mutate) {
110
+ const doc = this.read();
111
+ const servers = { ...doc.servers };
112
+ const result = mutate(servers);
113
+ if (result === false) return result;
114
+ this._persist({ ...doc, servers });
115
+ return result;
116
+ }
117
+
118
+ /** Persist only the UI config section, keeping servers untouched. */
119
+ writeUi(ui) {
120
+ if (!isPlainObject(ui)) throw new Error("ui config must be an object");
121
+ const doc = this.read();
122
+ this._persist({ ...doc, ui });
123
+ return ui;
124
+ }
125
+
126
+ /** Atomically write the document (temp file + rename, no backups). */
127
+ _persist(doc) {
128
+ const body = JSON.stringify(doc, null, 2) + "\n";
129
+ const dir = dirname(this.path);
130
+ mkdirSync(dir, { recursive: true });
131
+ const temp = join(dir, `.${randomBytes(6).toString("hex")}.tmp`);
132
+ writeFileSync(temp, body, "utf8");
133
+ renameSync(temp, this.path);
134
+ this.lastError = null;
135
+ }
136
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Browser-side types for @moon16u/dsh-plugin-mcp-console (client bundle).
3
+ */
4
+ import type * as React from "react";
5
+
6
+ /** Props injected into the settings section component. */
7
+ export interface McpSectionProps {
8
+ /** Locale-bound translator provided by the section registration. */
9
+ t: (key: string) => string;
10
+ }
11
+
12
+ /** The "MCP" settings section (mcp-manager-gui-spec.md). */
13
+ export declare const McpSection: React.FC<McpSectionProps>;
14
+ export declare function mcpConsoleApply(ctx: unknown): void;
15
+ export declare const mcpInject: string[];