@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
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[];
|