@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,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator: the console's core. It owns the fiber ledger
|
|
3
|
+
* (`Map<serverName, { fiber, config, toolNames, error, ... }>`) and every
|
|
4
|
+
* lifecycle operation on the official `@deepseek-ai/dsh-mcp-client`:
|
|
5
|
+
*
|
|
6
|
+
* load ctx.plugin(mcpClient, config) — one fiber per server
|
|
7
|
+
* update fiber.update(newConfig) — hot config swap
|
|
8
|
+
* disable fiber.dispose() — disconnect + unregister tools
|
|
9
|
+
* enable load again
|
|
10
|
+
* reconnect fiber.restart()
|
|
11
|
+
*
|
|
12
|
+
* The console implements ZERO MCP protocol code (plan red line): connection,
|
|
13
|
+
* reconnect backoff, tool discovery, `mcp__` naming and image bridging are
|
|
14
|
+
* all inherited from the official client.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency: every mutating operation runs through a serialized promise
|
|
17
|
+
* chain, because tool-ownership is observed by diffing the global tool
|
|
18
|
+
* registry around each load (public names of tools whose raw names are too
|
|
19
|
+
* long get hash-suffixed by the official client, so a plain prefix match is
|
|
20
|
+
* not enough — the plan's §5.3 diff approach).
|
|
21
|
+
*
|
|
22
|
+
* Conflict pre-check (plan §5.2): before assembling a fiber, the serverName
|
|
23
|
+
* is checked against (a) this ledger and (b) every LIVE mcp-client fiber in
|
|
24
|
+
* the registry — which covers servers declared manually in the profile's
|
|
25
|
+
* cordis config, regardless of which physical copy of dsh-mcp-client loaded
|
|
26
|
+
* them (this console vendors its own copy, so the official module-level
|
|
27
|
+
* namespace reservation cannot see across copies; the registry scan can).
|
|
28
|
+
*/
|
|
29
|
+
import { resolveMcpClient, toClientConfig, validateServerConfig } from "./clientAdapter.js";
|
|
30
|
+
import { ingestProfileMcpEntries, defaultProfilePatchYamlPath } from "./yamlMigrator.js";
|
|
31
|
+
|
|
32
|
+
/** cordis FiberState values (const enum, not exported at runtime). */
|
|
33
|
+
export const FiberState = {
|
|
34
|
+
PENDING: 0,
|
|
35
|
+
LOADING: 1,
|
|
36
|
+
ACTIVE: 2,
|
|
37
|
+
FAILED: 3,
|
|
38
|
+
DISPOSED: 4,
|
|
39
|
+
UNLOADING: 5,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Official initial sync window (default toolCallTimeoutMs) plus slack. */
|
|
43
|
+
export const CONNECT_GRACE_MS = 75000;
|
|
44
|
+
|
|
45
|
+
/** Error with a stable machine-readable code mapped to an HTTP status. */
|
|
46
|
+
export class ConsoleError extends Error {
|
|
47
|
+
/**
|
|
48
|
+
* @param {"invalid"|"conflict"|"not_found"|"internal"} code
|
|
49
|
+
* @param {string} message
|
|
50
|
+
*/
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errorMessage(error) {
|
|
58
|
+
if (error instanceof Error) return error.message;
|
|
59
|
+
return String(error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Orchestrator {
|
|
63
|
+
/**
|
|
64
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx owning context; fibers
|
|
65
|
+
* created from it are disposed automatically when it unloads.
|
|
66
|
+
* @param {object} options
|
|
67
|
+
* @param {import("./store.js").Store} options.globalStore
|
|
68
|
+
* @param {() => void} options.onChange invoked (already throttled upstream)
|
|
69
|
+
* after any state mutation worth broadcasting.
|
|
70
|
+
* @param {string} [options.profileYamlPath] profile patch layer to ingest.
|
|
71
|
+
*/
|
|
72
|
+
constructor(ctx, { globalStore, onChange, profileYamlPath }) {
|
|
73
|
+
this.ctx = ctx;
|
|
74
|
+
this.globalStore = globalStore;
|
|
75
|
+
this.projectStore = null;
|
|
76
|
+
this.projectRoot = null;
|
|
77
|
+
this.onChange = onChange ?? (() => {});
|
|
78
|
+
/** @type {Map<string, object>} serverName -> ledger entry */
|
|
79
|
+
this.ledger = new Map();
|
|
80
|
+
/** Resolved official client module (set by bootstrap). */
|
|
81
|
+
this.mcpClient = null;
|
|
82
|
+
/** Resolution failure of the official client, surfaced to the panel. */
|
|
83
|
+
this.clientError = null;
|
|
84
|
+
this._chain = Promise.resolve();
|
|
85
|
+
/** Profile patch layer ingested into the store (auto-ingest engine). */
|
|
86
|
+
this.profileYamlPath = profileYamlPath ?? defaultProfilePatchYamlPath();
|
|
87
|
+
/** Last ingest failure, surfaced to the panel (never silently dropped). */
|
|
88
|
+
this.ingestError = null;
|
|
89
|
+
/**
|
|
90
|
+
* Live index of per-tool disabled PUBLIC tool names across all servers,
|
|
91
|
+
* read by the execution guard (mcp-manager-gui-spec §3.4: a disabled tool
|
|
92
|
+
* stays registered but its calls are denied).
|
|
93
|
+
*/
|
|
94
|
+
this.disabledToolSet = new Set();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Rebuild the global disabled-tool index from the stores. */
|
|
98
|
+
rebuildDisabledToolSet() {
|
|
99
|
+
const set = new Set();
|
|
100
|
+
for (const config of this.storedServers().values()) {
|
|
101
|
+
if (Array.isArray(config.disabledTools)) {
|
|
102
|
+
for (const name of config.disabledTools) set.add(name);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.disabledToolSet = set;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The execution guard: denies calls to tools disabled per-tool in the
|
|
110
|
+
* panel. Registered once by the composition root (plain-context guards
|
|
111
|
+
* apply globally — the official dsh-tools contract).
|
|
112
|
+
*/
|
|
113
|
+
toolGuard(execution) {
|
|
114
|
+
const name = execution?.name;
|
|
115
|
+
if (typeof name === "string" && this.disabledToolSet.has(name)) {
|
|
116
|
+
return `mcp-console: tool "${name}" is disabled (toggle it in the MCP settings section)`;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Attach (or detach) the project-scope store following the session workspace. */
|
|
122
|
+
setProjectStore(store, root) {
|
|
123
|
+
this.projectStore = store ?? null;
|
|
124
|
+
this.projectRoot = root ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** All configured servers across scopes (stored configs, no live status). */
|
|
128
|
+
storedServers() {
|
|
129
|
+
const out = new Map();
|
|
130
|
+
for (const [name, config] of Object.entries(this.globalStore.read().servers)) {
|
|
131
|
+
out.set(name, { ...config, name, scope: config.scope === "project" ? "project" : "global" });
|
|
132
|
+
}
|
|
133
|
+
if (this.projectStore) {
|
|
134
|
+
for (const [name, config] of Object.entries(this.projectStore.read().servers)) {
|
|
135
|
+
out.set(name, { ...config, name, scope: "project" });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
storeFor(scope) {
|
|
142
|
+
return scope === "project" ? this.projectStore : this.globalStore;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Snapshot every globally registered tool name. */
|
|
146
|
+
toolNamesSnapshot() {
|
|
147
|
+
try {
|
|
148
|
+
return new Set(this.ctx.tools.schemas().map((schema) => schema.name));
|
|
149
|
+
} catch {
|
|
150
|
+
return new Set();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every live mcp-client serverName (this console's ledger plus external
|
|
156
|
+
* instances). Tool names are attributed by prefix first; the load-time
|
|
157
|
+
* diff only covers hash-suffixed names the prefix cannot see.
|
|
158
|
+
*/
|
|
159
|
+
liveServerNames() {
|
|
160
|
+
const names = new Set(this.ledger.keys());
|
|
161
|
+
try {
|
|
162
|
+
for (const [, runtime] of this.ctx.registry.entries()) {
|
|
163
|
+
if (runtime?.name !== "mcp-client") continue;
|
|
164
|
+
for (const fiber of runtime.fibers) {
|
|
165
|
+
const serverName = fiber?.config?.serverName;
|
|
166
|
+
if (typeof serverName === "string") names.add(serverName);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// registry shape drift: prefix matching against the ledger still applies
|
|
171
|
+
}
|
|
172
|
+
return names;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Live tool names attributed to one server: prefix matches (`mcp__<name>__`)
|
|
177
|
+
* plus names captured by this server's load diff that no OTHER live
|
|
178
|
+
* server's prefix owns — the diff alone cannot distinguish tools that
|
|
179
|
+
* OTHER servers registered concurrently during the load window (which
|
|
180
|
+
* happens at boot, when profile-declared entries start beside us).
|
|
181
|
+
*/
|
|
182
|
+
toolsOf(name, currentNames) {
|
|
183
|
+
const current = currentNames ?? this.toolNamesSnapshot();
|
|
184
|
+
const entry = this.ledger.get(name);
|
|
185
|
+
const prefix = `mcp__${name}__`;
|
|
186
|
+
const foreignPrefixes = [];
|
|
187
|
+
for (const other of this.liveServerNames()) {
|
|
188
|
+
if (other !== name) foreignPrefixes.push(`mcp__${other}__`);
|
|
189
|
+
}
|
|
190
|
+
const names = [];
|
|
191
|
+
for (const toolName of current) {
|
|
192
|
+
if (toolName.startsWith(prefix)) {
|
|
193
|
+
names.push(toolName);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!entry || !entry.toolNames.has(toolName)) continue;
|
|
197
|
+
if (foreignPrefixes.some((foreign) => toolName.startsWith(foreign))) continue;
|
|
198
|
+
names.push(toolName);
|
|
199
|
+
}
|
|
200
|
+
return names.sort();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* serverNames currently held by mcp-client fibers OUTSIDE this console:
|
|
205
|
+
* manual profile declarations and any other dynamic assembly. Derived from
|
|
206
|
+
* the live fiber registry — the authoritative runtime source.
|
|
207
|
+
*/
|
|
208
|
+
externalServerNames() {
|
|
209
|
+
const names = new Set();
|
|
210
|
+
try {
|
|
211
|
+
for (const [, runtime] of this.ctx.registry.entries()) {
|
|
212
|
+
if (runtime?.name !== "mcp-client") continue;
|
|
213
|
+
for (const fiber of runtime.fibers) {
|
|
214
|
+
const serverName = fiber?.config?.serverName;
|
|
215
|
+
if (typeof serverName === "string") names.add(serverName);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// registry shape drift: external names simply read empty until it heals
|
|
220
|
+
}
|
|
221
|
+
for (const name of this.ledger.keys()) names.delete(name);
|
|
222
|
+
return names;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Take over one mcp-client fiber the loader started for a profile YAML
|
|
227
|
+
* entry we just ingested: dispose it so our own loadOne can claim the
|
|
228
|
+
* serverName (the official client's namespace reservation is per app
|
|
229
|
+
* root, and both sides share the same module instance).
|
|
230
|
+
*/
|
|
231
|
+
async disposeExternalFiber(name) {
|
|
232
|
+
try {
|
|
233
|
+
for (const [, runtime] of this.ctx.registry.entries()) {
|
|
234
|
+
if (runtime?.name !== "mcp-client") continue;
|
|
235
|
+
for (const fiber of runtime.fibers) {
|
|
236
|
+
if (fiber?.config?.serverName === name && !this.ledger.has(name)) {
|
|
237
|
+
await fiber.dispose();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
this.ctx.logger.warn(`mcp-console: taking over "${name}" raised ${errorMessage(error)}`);
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Auto-ingest engine (dsh-mcp-ingest-export-plan §2.2): move profile-YAML
|
|
250
|
+
* MCP entries into the store and take over their live fibers. Safe to call
|
|
251
|
+
* repeatedly — a clean YAML is a no-op.
|
|
252
|
+
*/
|
|
253
|
+
async ingestFromProfile() {
|
|
254
|
+
let result;
|
|
255
|
+
try {
|
|
256
|
+
result = await ingestProfileMcpEntries({ store: this.globalStore, yamlPath: this.profileYamlPath });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
this.ingestError = errorMessage(error);
|
|
259
|
+
this.ctx.logger.error(`mcp-console: profile ingest failed: ${this.ingestError}`);
|
|
260
|
+
return { ingested: [], skipped: [] };
|
|
261
|
+
}
|
|
262
|
+
this.ingestError = null;
|
|
263
|
+
for (const name of result.ingested) {
|
|
264
|
+
await this.disposeExternalFiber(name);
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Pre-check one serverName before assembly (plan §5.2): shape, ledger
|
|
271
|
+
* duplicate, external conflicts. Returns an error string or null.
|
|
272
|
+
*/
|
|
273
|
+
precheck(name) {
|
|
274
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(name)) {
|
|
275
|
+
return `serverName "${name}" must match [A-Za-z0-9_-]{1,32}`;
|
|
276
|
+
}
|
|
277
|
+
if (this.ledger.has(name)) return `serverName "${name}" is already managed by this console`;
|
|
278
|
+
const external = this.externalServerNames();
|
|
279
|
+
if (external.has(name)) {
|
|
280
|
+
return `serverName "${name}" is already in use by an mcp-client instance outside this console (check profile cordis config / other managers)`;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Load every enabled server from the stores. Called once at plugin start. */
|
|
286
|
+
async bootstrap() {
|
|
287
|
+
this.rebuildDisabledToolSet();
|
|
288
|
+
await this.ingestFromProfile();
|
|
289
|
+
try {
|
|
290
|
+
this.mcpClient = await resolveMcpClient();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.clientError = errorMessage(error);
|
|
293
|
+
this.ctx.logger.error(`mcp-console: ${this.clientError}`);
|
|
294
|
+
this.onChange();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const stored = this.storedServers();
|
|
298
|
+
for (const [name, raw] of stored) {
|
|
299
|
+
if (raw.enabled === false) continue;
|
|
300
|
+
const { config, error } = validateServerConfig(raw);
|
|
301
|
+
if (error) {
|
|
302
|
+
this.ledger.set(name, { fiber: null, config: raw, toolNames: new Set(), error, pending: false, loadedAt: 0 });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const conflict = this.precheck(name);
|
|
306
|
+
if (conflict) {
|
|
307
|
+
this.ledger.set(name, { fiber: null, config, toolNames: new Set(), error: conflict, pending: false, loadedAt: 0 });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
await this.loadOne(name, config);
|
|
311
|
+
}
|
|
312
|
+
this.onChange();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Serialized mutators: each returns the API-facing result. */
|
|
316
|
+
|
|
317
|
+
run(operation) {
|
|
318
|
+
const next = this._chain.then(operation);
|
|
319
|
+
// keep the chain alive even when an operation rejects
|
|
320
|
+
this._chain = next.then(() => {}, () => {});
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
add(rawServer) {
|
|
325
|
+
return this.run(async () => {
|
|
326
|
+
const { config, error } = validateServerConfig(rawServer);
|
|
327
|
+
if (error) throw new ConsoleError("invalid", error);
|
|
328
|
+
if (config.scope === "project" && !this.projectStore) {
|
|
329
|
+
throw new ConsoleError("invalid", "project scope is unavailable: no session workspace is attached");
|
|
330
|
+
}
|
|
331
|
+
const store = this.storeFor(config.scope) ?? this.globalStore;
|
|
332
|
+
const existing = this.storedServers();
|
|
333
|
+
if (existing.has(config.name)) {
|
|
334
|
+
throw new ConsoleError("conflict", `serverName "${config.name}" already exists; edit it instead`);
|
|
335
|
+
}
|
|
336
|
+
const conflict = this.precheck(config.name);
|
|
337
|
+
if (conflict) throw new ConsoleError("conflict", conflict);
|
|
338
|
+
if (config.enabled !== false) await this.loadOne(config.name, config);
|
|
339
|
+
this.persistServer(config);
|
|
340
|
+
this.rebuildDisabledToolSet();
|
|
341
|
+
this.onChange();
|
|
342
|
+
return { config: this.publicConfig(config) };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
update(name, patch) {
|
|
347
|
+
return this.run(async () => {
|
|
348
|
+
const existing = this.storedServers().get(name);
|
|
349
|
+
if (!existing) throw new ConsoleError("not_found", `unknown server "${name}"`);
|
|
350
|
+
const merged = { ...existing, ...patch, name: patch?.name ?? name };
|
|
351
|
+
if (merged.name !== name) throw new ConsoleError("invalid", "renaming a server is not supported; remove and re-add");
|
|
352
|
+
if (merged.scope === "project" && !this.projectStore) {
|
|
353
|
+
throw new ConsoleError("invalid", "project scope is unavailable: no session workspace is attached");
|
|
354
|
+
}
|
|
355
|
+
// masked round-trip: the panel sends "••••" for unchanged secrets and
|
|
356
|
+
// "" for "remove this entry" — resolve both against the stored values.
|
|
357
|
+
if (patch && patch.env) merged.env = mergeMasked(existing.env, patch.env);
|
|
358
|
+
if (patch && patch.headers) merged.headers = mergeMasked(existing.headers, patch.headers);
|
|
359
|
+
const { config, error } = validateServerConfig(merged);
|
|
360
|
+
if (error) throw new ConsoleError("invalid", error);
|
|
361
|
+
const entry = this.ledger.get(name);
|
|
362
|
+
// A pure per-tool toggle (disabledTools only) needs no fiber reload:
|
|
363
|
+
// the execution guard reads the live index.
|
|
364
|
+
const toolsOnlyPatch =
|
|
365
|
+
patch !== null &&
|
|
366
|
+
typeof patch === "object" &&
|
|
367
|
+
Object.keys(patch).length === 1 &&
|
|
368
|
+
Array.isArray(patch.disabledTools);
|
|
369
|
+
if (toolsOnlyPatch) {
|
|
370
|
+
this.persistServer(config);
|
|
371
|
+
this.rebuildDisabledToolSet();
|
|
372
|
+
this.onChange();
|
|
373
|
+
return { config: this.publicConfig(config) };
|
|
374
|
+
}
|
|
375
|
+
if (entry && entry.fiber && config.enabled !== false) {
|
|
376
|
+
await this.swapFiber(entry, config);
|
|
377
|
+
} else if (entry && config.enabled === false) {
|
|
378
|
+
await this.disposeOne(name);
|
|
379
|
+
} else if (!entry && config.enabled !== false) {
|
|
380
|
+
const conflict = this.precheck(name);
|
|
381
|
+
if (conflict) throw new ConsoleError("conflict", conflict);
|
|
382
|
+
await this.loadOne(name, config);
|
|
383
|
+
}
|
|
384
|
+
// scope migration: drop the entry from the store it is leaving, so a
|
|
385
|
+
// global->project move never leaves a stale global copy that would
|
|
386
|
+
// resurrect when the project store detaches
|
|
387
|
+
if (config.scope !== existing.scope) {
|
|
388
|
+
const oldStore = this.storeFor(existing.scope);
|
|
389
|
+
if (oldStore && oldStore !== this.storeFor(config.scope)) {
|
|
390
|
+
oldStore.mutate((servers) => {
|
|
391
|
+
delete servers[name];
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
this.persistServer(config);
|
|
396
|
+
this.rebuildDisabledToolSet();
|
|
397
|
+
this.onChange();
|
|
398
|
+
return { config: this.publicConfig(config) };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
remove(name) {
|
|
403
|
+
return this.run(async () => {
|
|
404
|
+
const existing = this.storedServers().get(name);
|
|
405
|
+
if (!existing) throw new ConsoleError("not_found", `unknown server "${name}"`);
|
|
406
|
+
await this.disposeOne(name);
|
|
407
|
+
const store = this.storeFor(existing.scope) ?? this.globalStore;
|
|
408
|
+
store.mutate((servers) => {
|
|
409
|
+
delete servers[name];
|
|
410
|
+
});
|
|
411
|
+
this.rebuildDisabledToolSet();
|
|
412
|
+
this.onChange();
|
|
413
|
+
return { removed: name };
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
setEnabled(name, enabled) {
|
|
418
|
+
return this.run(async () => {
|
|
419
|
+
const existing = this.storedServers().get(name);
|
|
420
|
+
if (!existing) throw new ConsoleError("not_found", `unknown server "${name}"`);
|
|
421
|
+
if (enabled) {
|
|
422
|
+
if (this.ledger.has(name)) throw new ConsoleError("conflict", `server "${name}" is already enabled`);
|
|
423
|
+
const { config, error } = validateServerConfig({ ...existing, enabled: true });
|
|
424
|
+
if (error) throw new ConsoleError("invalid", error);
|
|
425
|
+
const conflict = this.precheck(name);
|
|
426
|
+
if (conflict) throw new ConsoleError("conflict", conflict);
|
|
427
|
+
await this.loadOne(name, config);
|
|
428
|
+
this.persistServer(config);
|
|
429
|
+
} else {
|
|
430
|
+
await this.disposeOne(name);
|
|
431
|
+
this.persistServer({ ...existing, enabled: false });
|
|
432
|
+
}
|
|
433
|
+
this.onChange();
|
|
434
|
+
return { name, enabled };
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
reconnect(name) {
|
|
439
|
+
return this.run(async () => {
|
|
440
|
+
const entry = this.ledger.get(name);
|
|
441
|
+
if (!entry) throw new ConsoleError("not_found", `server "${name}" is not loaded`);
|
|
442
|
+
if (!entry.fiber) throw new ConsoleError("invalid", entry.error ?? "server has no fiber to restart");
|
|
443
|
+
await this.swapFiber(entry, entry.config);
|
|
444
|
+
this.onChange();
|
|
445
|
+
return { name };
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Panel refresh (mcp-manager-gui-spec §3.1): re-examine health and tool
|
|
451
|
+
* lists. Managed servers that read as failed (startup error, or connected
|
|
452
|
+
* with zero tools past the grace window) are restarted; everything else
|
|
453
|
+
* just re-derives its live snapshot.
|
|
454
|
+
*/
|
|
455
|
+
refresh() {
|
|
456
|
+
return this.run(async () => {
|
|
457
|
+
// sync config first: newly written profile-YAML entries become managed
|
|
458
|
+
const ingest = await this.ingestFromProfile();
|
|
459
|
+
for (const name of ingest.ingested) {
|
|
460
|
+
const raw = this.storedServers().get(name);
|
|
461
|
+
if (!raw || raw.enabled === false) continue;
|
|
462
|
+
const { config, error } = validateServerConfig(raw);
|
|
463
|
+
if (error) continue;
|
|
464
|
+
const conflict = this.precheck(name);
|
|
465
|
+
if (conflict) continue;
|
|
466
|
+
await this.loadOne(name, config);
|
|
467
|
+
}
|
|
468
|
+
const restarted = [];
|
|
469
|
+
for (const [name, entry] of [...this.ledger]) {
|
|
470
|
+
const broken =
|
|
471
|
+
entry.error !== null ||
|
|
472
|
+
(entry.fiber !== null && !entry.pending && entry.fiber.state === FiberState.FAILED) ||
|
|
473
|
+
(entry.fiber !== null && entry.fiber.state === FiberState.ACTIVE &&
|
|
474
|
+
Date.now() - entry.loadedAt >= CONNECT_GRACE_MS && this.toolsOf(name).length === 0);
|
|
475
|
+
if (broken && entry.fiber !== null) {
|
|
476
|
+
await this.swapFiber(entry, entry.config);
|
|
477
|
+
restarted.push(name);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
this.rebuildDisabledToolSet();
|
|
481
|
+
this.onChange();
|
|
482
|
+
return { restarted, ingested: ingest.ingested };
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Import parsed servers (from mcpServers JSON). Existing names are skipped
|
|
488
|
+
* and reported, never overwritten (plan §5.5).
|
|
489
|
+
* @param {{ name: string, config: object }[]} parsed
|
|
490
|
+
*/
|
|
491
|
+
importServers(parsed) {
|
|
492
|
+
return this.run(async () => {
|
|
493
|
+
const added = [];
|
|
494
|
+
const skipped = [];
|
|
495
|
+
for (const { name, config } of parsed) {
|
|
496
|
+
const { config: validated, error } = validateServerConfig({ ...config, name });
|
|
497
|
+
if (error) {
|
|
498
|
+
skipped.push({ name, reason: error });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (this.storedServers().has(validated.name) || added.some((item) => item.name === validated.name)) {
|
|
502
|
+
skipped.push({ name: validated.name, reason: "name already exists" });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const conflict = this.precheck(validated.name);
|
|
506
|
+
if (conflict) {
|
|
507
|
+
skipped.push({ name: validated.name, reason: conflict });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
await this.loadOne(validated.name, validated);
|
|
511
|
+
this.persistServer(validated);
|
|
512
|
+
added.push(this.publicConfig(validated));
|
|
513
|
+
}
|
|
514
|
+
this.rebuildDisabledToolSet();
|
|
515
|
+
this.onChange();
|
|
516
|
+
return { added, skipped };
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
persistServer(config) {
|
|
523
|
+
const store = this.storeFor(config.scope) ?? this.globalStore;
|
|
524
|
+
const { name, ...rest } = config;
|
|
525
|
+
store.mutate((servers) => {
|
|
526
|
+
servers[name] = rest;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async loadOne(name, config) {
|
|
531
|
+
const entry = {
|
|
532
|
+
fiber: null,
|
|
533
|
+
config,
|
|
534
|
+
toolNames: new Set(),
|
|
535
|
+
error: null,
|
|
536
|
+
pending: true,
|
|
537
|
+
loadedAt: Date.now(),
|
|
538
|
+
};
|
|
539
|
+
this.ledger.set(name, entry);
|
|
540
|
+
try {
|
|
541
|
+
if (!this.mcpClient) this.mcpClient = await resolveMcpClient();
|
|
542
|
+
const before = this.toolNamesSnapshot();
|
|
543
|
+
const fiber = this.ctx.plugin(this.mcpClient, toClientConfig(config));
|
|
544
|
+
entry.fiber = fiber;
|
|
545
|
+
await fiber;
|
|
546
|
+
entry.pending = false;
|
|
547
|
+
entry.loadedAt = Date.now();
|
|
548
|
+
entry.error = null;
|
|
549
|
+
const after = this.toolNamesSnapshot();
|
|
550
|
+
entry.toolNames = new Set([...after].filter((toolName) => !before.has(toolName)));
|
|
551
|
+
} catch (error) {
|
|
552
|
+
entry.pending = false;
|
|
553
|
+
entry.error = errorMessage(error);
|
|
554
|
+
entry.toolNames = new Set();
|
|
555
|
+
if (entry.fiber && entry.fiber.state !== FiberState.DISPOSED) {
|
|
556
|
+
try {
|
|
557
|
+
await entry.fiber.dispose();
|
|
558
|
+
} catch {
|
|
559
|
+
// a failed startup fiber that refuses disposal is left to
|
|
560
|
+
// context teardown; the ledger entry keeps the error either way
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Hot-swap an existing fiber's config (update/reconnect both land here). */
|
|
567
|
+
async swapFiber(entry, config) {
|
|
568
|
+
const fiber = entry.fiber;
|
|
569
|
+
const before = this.toolNamesSnapshot();
|
|
570
|
+
entry.pending = true;
|
|
571
|
+
entry.error = null;
|
|
572
|
+
try {
|
|
573
|
+
if (!this.mcpClient) this.mcpClient = await resolveMcpClient();
|
|
574
|
+
if (fiber) {
|
|
575
|
+
await fiber.update(toClientConfig(config));
|
|
576
|
+
} else {
|
|
577
|
+
const created = this.ctx.plugin(this.mcpClient, toClientConfig(config));
|
|
578
|
+
entry.fiber = created;
|
|
579
|
+
await created;
|
|
580
|
+
}
|
|
581
|
+
entry.config = config;
|
|
582
|
+
entry.pending = false;
|
|
583
|
+
entry.loadedAt = Date.now();
|
|
584
|
+
const after = this.toolNamesSnapshot();
|
|
585
|
+
entry.toolNames = new Set([...after].filter((toolName) => !before.has(toolName)));
|
|
586
|
+
} catch (error) {
|
|
587
|
+
entry.pending = false;
|
|
588
|
+
entry.error = errorMessage(error);
|
|
589
|
+
entry.toolNames = new Set();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async disposeOne(name) {
|
|
594
|
+
const entry = this.ledger.get(name);
|
|
595
|
+
this.ledger.delete(name);
|
|
596
|
+
if (!entry) return;
|
|
597
|
+
if (entry.fiber && entry.fiber.state !== FiberState.DISPOSED) {
|
|
598
|
+
try {
|
|
599
|
+
await entry.fiber.dispose();
|
|
600
|
+
} catch (error) {
|
|
601
|
+
this.ctx.logger.warn(`mcp-console: disposing "${name}" raised ${errorMessage(error)}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Stored config with secret-shaped values masked, for API responses. */
|
|
607
|
+
publicConfig(config) {
|
|
608
|
+
const out = { ...config };
|
|
609
|
+
if (out.env) out.env = maskValues(out.env);
|
|
610
|
+
if (out.headers) out.headers = maskValues(out.headers);
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function maskValues(record) {
|
|
616
|
+
const out = {};
|
|
617
|
+
for (const [key, value] of Object.entries(record)) {
|
|
618
|
+
out[key] = typeof value === "string" && value.length > 0 ? "••••" : value;
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Merge a masked record coming from the panel with the stored secrets:
|
|
625
|
+
* `MASK` keeps the stored value, `""` removes the entry, anything else
|
|
626
|
+
* replaces it.
|
|
627
|
+
*/
|
|
628
|
+
export function mergeMasked(stored, incoming) {
|
|
629
|
+
const out = { ...stored };
|
|
630
|
+
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
631
|
+
if (value === "••••") continue;
|
|
632
|
+
if (value === "" || value === null || value === undefined) delete out[key];
|
|
633
|
+
else out[key] = String(value);
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|