@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,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
+ }