@gyp8735abc/dsh-mcp-manager 0.0.0-stage → 0.2.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/index.js ADDED
@@ -0,0 +1,1099 @@
1
+ // dsh-mcp-manager —host half.
2
+ //
3
+ // One MCP supervisor for the web profile:
4
+ // - inventory: global servers (from $DSH_HOME/mcp.claude.json, generated
5
+ // from ~/.claude.json), project servers (each dsh workspace's .mcp.json),
6
+ // and manual servers (other live @deepseek-ai/dsh-mcp-client loader
7
+ // entries, e.g. profile-patch entries like codebase / openpencil).
8
+ // - state: a `mcp` settings namespace storing PER-WORKSPACE disabled lists
9
+ // ({ projects: [{ path, disabled: [serverName...] }] }) in the settings
10
+ // document (~/.dsh/settings.yaml). A workspace's disabled list covers BOTH
11
+ // its own project servers AND the global servers —so global MCP usage is
12
+ // per-workspace (workspace A may enable 1 global server, workspace B 3).
13
+ // Legacy plain names in `disabled` keep matching the workspace-qualified
14
+ // runtime identities, so existing config needs no migration.
15
+ // - mounting: the manager owns every MCP connection. The mount set is the
16
+ // UNION of every workspace that currently has a live session (host
17
+ // `sessions` service + session/created|disposed events); a workspace's
18
+ // MCPs stay up as long as any of its sessions is open, so switching the
19
+ // focused session NEVER stops another workspace's MCPs. Project servers
20
+ // are mounted per (workspace, server) with workspace-qualified runtime
21
+ // serverNames (`<slug>__<name>` -> tools `mcp__<workspace>__<name>__<tool>`),
22
+ // each with its own config/cwd; globals are one shared process mounted
23
+ // when any live workspace enables them; manual loader entries follow
24
+ // "wanted by any live workspace". A per-session system-prompt/assemble
25
+ // filter then drops other workspaces' `mcp__<workspace>__*` tools, so a
26
+ // session only ever receives its own workspace's MCP tools.
27
+ // - API:
28
+ // GET /api/dsh-mcp-manager/inventory[?active=<path>]
29
+ // POST /api/dsh-mcp-manager/activate { path }
30
+ // POST /api/dsh-mcp-manager/set { path, kind, name, enabled }
31
+ //
32
+ // The browser half renders a popup opened from each workspace's MCP button and
33
+ // writes toggles through the standard settings wire (settings.mutate), which
34
+ // this plugin watches.
35
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
36
+ import { readdir } from "node:fs/promises";
37
+ import { join, resolve, normalize, basename, dirname } from "node:path";
38
+ import { homedir } from "node:os";
39
+ import { spawn } from "node:child_process";
40
+ import { installSkillFilter } from "./skill-filter.js";
41
+ import { registerClaudeSkillRoots } from "./skill-roots.js";
42
+
43
+ export const name = "dsh-mcp-manager";
44
+
45
+ export const inject = ["webServer", "loader"];
46
+
47
+ const MCP_CLIENT = "@deepseek-ai/dsh-mcp-client";
48
+ const NS = "mcp";
49
+ const INVENTORY_PATH = "/api/dsh-mcp-manager/inventory";
50
+ const ACTIVATE_PATH = "/api/dsh-mcp-manager/activate";
51
+ const SET_PATH = "/api/dsh-mcp-manager/set";
52
+
53
+ const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
54
+
55
+ /**
56
+ * This plugin owns its state file instead of the DSH settings service.
57
+ *
58
+ * dsh 0.2.x replaced `settings.register(ns, schema)` with a per-entry model:
59
+ * a settings namespace is a composed profile entry id and its schema is that
60
+ * entry's Config, so a plugin can no longer claim an arbitrary namespace like
61
+ * `mcp`. Keeping the state in `<home>/mcp-manager/state.json` keeps the same
62
+ * data shape as before and works on both the old and the new runtime.
63
+ */
64
+ const statePath = () => join(dshHome(), "mcp-manager", "state.json");
65
+
66
+ /** Normalize any parsed state document to `{ projects: [...] }`. */
67
+ function normalizeState(value) {
68
+ const projects = Array.isArray(value?.projects) ? value.projects : [];
69
+ return {
70
+ projects: projects
71
+ .filter((row) => row !== null && typeof row === "object" && typeof row.path === "string" && row.path.length > 0)
72
+ .map((row) => ({
73
+ path: row.path,
74
+ disabled: Array.isArray(row.disabled) ? row.disabled.filter((name) => typeof name === "string") : [],
75
+ disabledSkills: Array.isArray(row.disabledSkills) ? row.disabledSkills.filter((name) => typeof name === "string") : [],
76
+ })),
77
+ };
78
+ }
79
+
80
+ /** The legacy `mcp` section of the removed `<home>/settings.yaml` document. */
81
+ function readLegacySection() {
82
+ for (const candidate of [join(dshHome(), "settings.yaml"), join(dshHome(), "settings.yaml.imported")]) {
83
+ if (!existsSync(candidate)) continue;
84
+ try {
85
+ const text = readFileSync(candidate, "utf8");
86
+ // The section is a small, flat list of per-workspace rows; a focused
87
+ // reader avoids depending on a YAML package.
88
+ const section = extractYamlSection(text, NS);
89
+ if (section !== null) {
90
+ const parsed = parseProjectRows(section);
91
+ if (parsed.length > 0) return { projects: parsed };
92
+ }
93
+ } catch {
94
+ /* an unreadable legacy document must not block startup */
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /** Slice one top-level `key:` block (its indented body) out of a YAML document. */
101
+ function extractYamlSection(text, key) {
102
+ const lines = text.split(/\r?\n/);
103
+ const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: `));
104
+ if (start < 0) return null;
105
+ const body = [];
106
+ for (let index = start + 1; index < lines.length; index += 1) {
107
+ const line = lines[index];
108
+ if (line.trim().length > 0 && !/^\s/.test(line)) break;
109
+ body.push(line);
110
+ }
111
+ return body.join("\n");
112
+ }
113
+
114
+ /** Read `- path: ... / disabled: [...] / disabledSkills: [...]` rows from a section body. */
115
+ function parseProjectRows(section) {
116
+ const rows = [];
117
+ let current = null;
118
+ let field = null;
119
+ for (const line of section.split(/\r?\n/)) {
120
+ const item = /^\s*-\s*path:\s*(.+?)\s*$/.exec(line);
121
+ if (item !== null) {
122
+ current = { path: unquoteYaml(item[1]), disabled: [], disabledSkills: [] };
123
+ rows.push(current);
124
+ field = null;
125
+ continue;
126
+ }
127
+ if (current === null) continue;
128
+ const inline = /^\s*(disabled|disabledSkills):\s*\[(.*)\]\s*$/.exec(line);
129
+ if (inline !== null) {
130
+ current[inline[1]] = inline[2]
131
+ .split(",")
132
+ .map((part) => unquoteYaml(part.trim()))
133
+ .filter((part) => part.length > 0);
134
+ field = null;
135
+ continue;
136
+ }
137
+ const block = /^\s*(disabled|disabledSkills):\s*$/.exec(line);
138
+ if (block !== null) {
139
+ current[block[1]] = [];
140
+ field = block[1];
141
+ continue;
142
+ }
143
+ const entry = /^\s*-\s*(.+?)\s*$/.exec(line);
144
+ if (entry !== null && field !== null) {
145
+ const value = unquoteYaml(entry[1]);
146
+ if (value.length > 0) current[field].push(value);
147
+ }
148
+ }
149
+ return rows;
150
+ }
151
+
152
+ /** Strip surrounding quotes from one scalar YAML value. */
153
+ function unquoteYaml(value) {
154
+ const text = String(value).trim();
155
+ if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
156
+ return text.slice(1, -1);
157
+ }
158
+ return text;
159
+ }
160
+
161
+ /** Generic spawner command basenames that are too generic to identify a server. */
162
+ const GENERIC_MARKER_TOKENS = new Set(["python", "python3", "node", "npx", "npm", "cmd", "powershell", "pwsh", "bash", "sh", "dot", "exe", "mcp"]);
163
+
164
+ /**
165
+ * Derive a distinctive process-command-line marker for one mcp-client config,
166
+ * so orphaned children of a stopped server can be found and killed. Prefers the
167
+ * command basename; falls back to the longest distinctive argument token.
168
+ * Returns null when nothing distinctive exists (sweep is then skipped).
169
+ */
170
+ function markerFor(config) {
171
+ const command = typeof config?.command === "string" ? config.command : "";
172
+ const args = Array.isArray(config?.args) ? config.args : [];
173
+ const candidates = [];
174
+ const base = command.split(/[\\/]/).pop() ?? "";
175
+ const stem = base.replace(/\.[^.]+$/, "");
176
+ if (stem.length >= 4) candidates.push(stem);
177
+ for (const arg of args) {
178
+ for (const part of String(arg).split(/[\\/\s=]/).filter(Boolean)) {
179
+ if (part.length >= 4) candidates.push(part);
180
+ }
181
+ }
182
+ const distinct = candidates.filter((candidate) => !GENERIC_MARKER_TOKENS.has(candidate.toLowerCase()));
183
+ if (distinct.length === 0) return null;
184
+ return [...distinct].sort((a, b) => b.length - a.length)[0];
185
+ }
186
+
187
+ /**
188
+ * Read a directory with entry types; rejects propagate (callers treat ENOENT as
189
+ * an absent root).
190
+ */
191
+ function readdirWithTypes(path) {
192
+ return readdir(path, { withFileTypes: true, encoding: "utf8" });
193
+ }
194
+
195
+ /**
196
+ * Minimal SKILL.md frontmatter parser: extracts `name` and `description`
197
+ * (quoted or plain). Frontmatter-less files (Claude Code style) fall back to
198
+ * the file name + first content line. Returns null when nothing usable exists.
199
+ */
200
+ function parseSkillFile(path) {
201
+ let raw;
202
+ try {
203
+ raw = readFileSync(path, "utf8");
204
+ } catch {
205
+ return null;
206
+ }
207
+ const firstLineEnd = raw.indexOf("\n");
208
+ if (firstLineEnd < 0 || raw.slice(0, firstLineEnd).replace(/\r$/, "") !== "---") {
209
+ return fallbackSkillMeta(path, raw);
210
+ }
211
+ const closing = raw.indexOf("\n---", firstLineEnd + 1);
212
+ if (closing < 0) return null;
213
+ const frontmatter = raw.slice(firstLineEnd + 1, closing);
214
+ const name = frontmatterValue(frontmatter, "name");
215
+ const description = frontmatterValue(frontmatter, "description");
216
+ if (name === null || description === null) return null;
217
+ return { name, description };
218
+ }
219
+
220
+ /** Frontmatter-less fallback (Claude Code compatibility): name from the skill
221
+ * identity — the parent directory for a <name>/SKILL.md bundle, else the file
222
+ * stem — and description from the first content line. */
223
+ function fallbackSkillMeta(path, raw) {
224
+ const parts = path.split(/[\\/]/);
225
+ const basename = parts.pop() ?? "";
226
+ let stem;
227
+ if (basename === "SKILL.md") stem = parts.pop() ?? "";
228
+ else if (basename.endsWith(".md")) stem = basename.slice(0, -3);
229
+ else stem = basename;
230
+ if (stem.length === 0 || stem.length > 64) return null;
231
+ const line = raw
232
+ .split(/\r?\n/)
233
+ .map((l) => l.replace(/^#+\s*/, "").trim())
234
+ .find((l) => l.length > 0);
235
+ if (line === void 0) return null;
236
+ return { name: stem, description: line.slice(0, 200) };
237
+ }
238
+
239
+ function frontmatterValue(frontmatter, key) {
240
+ const match = new RegExp(`^${key}\\s*:\\s*(.*)$`, "m").exec(frontmatter);
241
+ if (match === null || match[1] === void 0) return null;
242
+ let value = match[1].trim();
243
+ if (
244
+ (value.startsWith('"') && value.endsWith('"')) ||
245
+ (value.startsWith("'") && value.endsWith("'"))
246
+ ) {
247
+ value = value.slice(1, -1);
248
+ }
249
+ return value.length > 0 ? value : null;
250
+ }
251
+
252
+ /**
253
+ * Kill processes matching `marker` whose parent is already gone (orphans of a
254
+ * stopped server). Windows venv launchers (Scripts/python.exe) spawn the real
255
+ * interpreter as a child, so killing the direct child orphans the rest —this
256
+ * sweeps those leftovers without touching unrelated processes whose parent is
257
+ * still alive (e.g. the same server running under Claude Code).
258
+ */
259
+ function killOrphanTree(marker) {
260
+ if (marker === null || marker === void 0 || marker.length === 0) return;
261
+ const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
262
+ const script =
263
+ "$procs = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '" +
264
+ escaped +
265
+ "' }; " +
266
+ "$ids = @(Get-CimInstance Win32_Process | ForEach-Object { $_.ProcessId }); " +
267
+ "foreach ($p in $procs) { if ($ids -notcontains $p.ParentProcessId) { & taskkill /F /T /PID $p.ProcessId 2>$null | Out-Null } }";
268
+ const child = spawn("powershell", ["-NoProfile", "-Command", script], { stdio: "ignore", windowsHide: true });
269
+ child.on("error", (error) => {
270
+ process.stderr.write(`dsh-mcp-manager: orphan sweep failed: ${String(error)}\n`);
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Retried orphan sweep: the MCP SDK's transport close has a ~4s grace window
276
+ * (stdin end -> SIGTERM -> SIGKILL), so the direct child may still be alive
277
+ * when the first sweep runs. Schedule a few passes so the eventual orphans
278
+ * (the launcher's own children) are caught once the direct child dies.
279
+ */
280
+ function scheduleOrphanSweep(marker) {
281
+ if (marker === null || marker === void 0 || marker.length === 0) return;
282
+ for (const delay of [1000, 3000, 6000]) {
283
+ setTimeout(() => killOrphanTree(marker), delay);
284
+ }
285
+ }
286
+
287
+ function serverNameOk(name) {
288
+ return /^[A-Za-z0-9_-]{1,32}$/.test(name);
289
+ }
290
+
291
+ function readJson(path) {
292
+ try {
293
+ return JSON.parse(readFileSync(path, "utf8"));
294
+ } catch (error) {
295
+ if (error?.code === "ENOENT") return null;
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ /** Claude-Code-style .mcp.json entry -> dsh mcp-client config (null when unsupported). */
301
+ function toMcpConfig(name, cfg) {
302
+ const type = cfg?.type || (cfg?.url ? "streamable-http" : "stdio");
303
+ if (type === "stdio" && cfg?.command) {
304
+ const config = { serverName: name, transport: "stdio", command: cfg.command };
305
+ if (Array.isArray(cfg.args) && cfg.args.length > 0) config.args = cfg.args;
306
+ if (cfg.env && Object.keys(cfg.env).length > 0) config.env = cfg.env;
307
+ if (cfg.cwd) config.cwd = cfg.cwd;
308
+ return config;
309
+ }
310
+ // Claude Code's .mcp.json also spells streamable HTTP as "http".
311
+ if ((type === "http" || type === "streamable-http") && cfg?.url) {
312
+ const config = { serverName: name, transport: "streamable-http", url: cfg.url };
313
+ if (cfg.headers) config.headers = cfg.headers;
314
+ return config;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ /** Short, filesystem-safe slug for one workspace directory (basename). */
320
+ function workspaceSlug(path) {
321
+ const base = basename(resolve(String(path))).replace(/[^A-Za-z0-9_-]/g, "_");
322
+ return base.length > 0 ? base : "ws";
323
+ }
324
+
325
+ /** One unique slug per workspace path; duplicate basenames get "-2", "-3", … */
326
+ function uniqueSlugs(paths) {
327
+ const result = new Map();
328
+ const seen = new Map();
329
+ for (const p of paths) {
330
+ const base = workspaceSlug(p);
331
+ const count = (seen.get(base) ?? 0) + 1;
332
+ seen.set(base, count);
333
+ result.set(normKey(p), count > 1 ? `${base}-${count}` : base);
334
+ }
335
+ return result;
336
+ }
337
+
338
+ /** Normalize a project path for display and for settings keys. */
339
+ function normPath(p) {
340
+ return normalize(String(p));
341
+ }
342
+
343
+ /** Case-insensitive dedup key (Windows). */
344
+ function normKey(p) {
345
+ return normPath(p).toLowerCase();
346
+ }
347
+
348
+ function readBody(req) {
349
+ return new Promise((resolveBody, reject) => {
350
+ const chunks = [];
351
+ req.on("data", (chunk) => chunks.push(chunk));
352
+ req.on("end", () => {
353
+ try {
354
+ resolveBody(chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {});
355
+ } catch (error) {
356
+ reject(error);
357
+ }
358
+ });
359
+ req.on("error", reject);
360
+ });
361
+ }
362
+
363
+ function sendJson(res, status, value) {
364
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
365
+ res.end(JSON.stringify(value, null, 2));
366
+ }
367
+
368
+ class McpManager {
369
+ constructor(ctx) {
370
+ this.ctx = ctx;
371
+ this.projectPath = resolve(process.cwd()); // dsh launch directory
372
+ this.activePath = this.projectPath; // focused workspace (display + fallback)
373
+ this.state = { projects: [] };
374
+ this.globalServers = new Map(); // serverName -> mcp-client config (mcp.claude.json + ~/.claude.json)
375
+ this.globalSource = null; // where the global list was read from, for the popup
376
+ this.workspacePaths = new Map(); // normKey -> display path (launch dir + dsh workspaces)
377
+ this.projectServersByPath = new Map(); // normKey -> { path, servers: Map<serverName, config> }
378
+ this.fibers = new Map(); // runtime serverName -> Fiber (union of all live workspaces' sets)
379
+ this.fiberConfigs = new Map(); // runtime serverName -> config (for orphan sweeping)
380
+ this.applyQueue = Promise.resolve();
381
+ this.disposers = [];
382
+ }
383
+
384
+ /**
385
+ * Load the global server list from two sources; the live one is read last so
386
+ * it wins a same-named server:
387
+ *
388
+ * 1. `$DSH_HOME/mcp.claude.json` — the dsh-side list that
389
+ * `$DSH_HOME/bin/dsh-mcp-sync.mjs` generates from `~/.claude.json`. It is
390
+ * optional: when that script was never run (the packaged app never runs
391
+ * it) the file is simply absent.
392
+ * 2. `~/.claude.json` (or `$CLAUDE_CONFIG_DIR/.claude.json`) — Claude Code's
393
+ * own `mcpServers`. Read directly, so editing Claude's config is enough and
394
+ * no sync step can be forgotten. Its entries have the `.mcp.json` shape,
395
+ * so the project normalizer is reused for them.
396
+ */
397
+ loadGlobal() {
398
+ const generatedFile = join(dshHome(), "mcp.claude.json");
399
+ const generatedDoc = readJson(generatedFile);
400
+ for (const entry of Array.isArray(generatedDoc) ? generatedDoc : []) {
401
+ const config = entry?.config;
402
+ const serverName = config?.serverName;
403
+ if (typeof serverName === "string" && serverNameOk(serverName)) this.globalServers.set(serverName, config);
404
+ }
405
+ const fromGenerated = this.globalServers.size;
406
+
407
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR;
408
+ const claudeFile = join(claudeDir !== undefined && claudeDir.trim().length > 0 ? claudeDir : homedir(), ".claude.json");
409
+ const claudeDoc = readJson(claudeFile);
410
+ let fromClaude = 0;
411
+ for (const [name, cfg] of Object.entries(claudeDoc?.mcpServers ?? {})) {
412
+ if (!serverNameOk(name)) {
413
+ this.ctx.logger?.warn(`dsh-mcp-manager: global server "${name}" in ${claudeFile} skipped (name is not usable as a dsh serverName)`);
414
+ continue;
415
+ }
416
+ const config = toMcpConfig(name, cfg);
417
+ if (config === null) {
418
+ this.ctx.logger?.warn(`dsh-mcp-manager: global server "${name}" in ${claudeFile} skipped (unsupported transport)`);
419
+ continue;
420
+ }
421
+ this.globalServers.set(name, config);
422
+ fromClaude += 1;
423
+ }
424
+ this.globalSource = { generatedFile, claudeFile, fromGenerated, fromClaude };
425
+ }
426
+
427
+ /**
428
+ * Re-discover every project (dsh workspaces + launch dir) and their
429
+ * .mcp.json servers. Called at boot AND on every inventory request / activate
430
+ * so workspaces added after boot and edited .mcp.json files show up without a
431
+ * restart. Never throws: a failing read keeps the previous project table.
432
+ */
433
+ rescanProjects() {
434
+ const candidates = new Map(); // normKey -> display path
435
+ const add = (p) => {
436
+ if (typeof p !== "string" || p.trim().length === 0) return;
437
+ const normalized = normPath(p);
438
+ candidates.set(normKey(normalized), normalized);
439
+ };
440
+ add(this.projectPath);
441
+ try {
442
+ const workspaces = this.ctx.get("workspaceRegistry")?.list?.();
443
+ for (const workspace of workspaces ?? []) add(workspace?.path);
444
+ } catch {
445
+ /* workspace registry optional */
446
+ }
447
+
448
+ const next = new Map();
449
+ for (const [key, dir] of candidates) {
450
+ const mcpFile = join(dir, ".mcp.json");
451
+ if (!existsSync(mcpFile)) continue;
452
+ const doc = readJson(mcpFile);
453
+ const servers = new Map();
454
+ for (const [serverName, cfg] of Object.entries(doc?.mcpServers ?? {})) {
455
+ if (!serverNameOk(serverName)) {
456
+ this.ctx.logger?.warn(`dsh-mcp-manager: skip server "${serverName}" in ${dir} (invalid name)`);
457
+ continue;
458
+ }
459
+ const config = toMcpConfig(serverName, cfg);
460
+ if (config === null) {
461
+ this.ctx.logger?.warn(`dsh-mcp-manager: skip server "${serverName}" in ${dir} (unsupported transport)`);
462
+ continue;
463
+ }
464
+ // Claude Code semantics: a stdio server without an explicit `cwd` runs
465
+ // in the WORKSPACE directory, so relative paths in its args (e.g.
466
+ // `--db .codebase/codebase.db`) resolve inside the owning workspace
467
+ // instead of the dsh launch directory — each workspace's same-named
468
+ // server (e.g. `codebase`) points at ITS OWN database.
469
+ if (config.transport === "stdio" && !config.cwd) config.cwd = dir;
470
+ servers.set(serverName, config);
471
+ }
472
+ if (servers.size === 0) continue;
473
+ next.set(key, { path: dir, servers });
474
+ }
475
+ this.workspacePaths = candidates;
476
+ this.projectServersByPath = next;
477
+ this.qualifyProjectServers();
478
+ }
479
+
480
+ /**
481
+ * Give every project server a workspace-qualified runtime serverName
482
+ * `<workspaceSlug>__<name>` (e.g. `xxsbm__codebase`), producing model-facing
483
+ * tool names `mcp__<workspace>__<name>__<tool>`. Two reasons:
484
+ * - the tool registry is Host-process-global, and the union model mounts
485
+ * several workspaces' sets in parallel — the workspace prefix keeps each
486
+ * (workspace, server) pair a distinct process AND tool namespace;
487
+ * - the per-session filter (system-prompt/assemble middleware) drops
488
+ * `mcp__<workspace>__*` tools of OTHER workspaces, so a session never
489
+ * receives another workspace's MCP tools.
490
+ * Global servers and manual loader entries keep their plain serverName.
491
+ */
492
+ qualifyProjectServers() {
493
+ const slugs = uniqueSlugs([...this.workspacePaths.values()]);
494
+ this.workspaceSlugs = slugs; // normKey -> slug
495
+ this.workspaceSlugSet = new Set(slugs.values());
496
+ const reserved = new Set();
497
+ for (const name of this.globalServers.keys()) reserved.add(name.toLowerCase());
498
+ for (const entry of this.loaderEntriesByServer()) {
499
+ const name = entry.options?.config?.serverName;
500
+ if (typeof name === "string") reserved.add(name.toLowerCase());
501
+ }
502
+ for (const row of this.projectServersByPath.values()) {
503
+ const slug = slugs.get(normKey(row.path));
504
+ for (const [name, config] of row.servers) {
505
+ let candidate = `${slug}__${name}`.slice(0, 32);
506
+ let qualified = candidate;
507
+ let n = 2;
508
+ while (reserved.has(qualified.toLowerCase())) {
509
+ const suffix = `-${n++}`;
510
+ qualified = `${candidate.slice(0, 32 - suffix.length)}${suffix}`;
511
+ }
512
+ reserved.add(qualified.toLowerCase());
513
+ config.serverName = qualified;
514
+ }
515
+ }
516
+ }
517
+
518
+ /** Workspace slug (basename) for the workspace containing `cwd`. */
519
+ slugForCwd(cwd) {
520
+ const path = this.workspacePathForCwd(cwd);
521
+ if (path === null) return null;
522
+ return this.workspaceSlugs?.get(normKey(path)) ?? null;
523
+ }
524
+
525
+ /**
526
+ * Whether one model-facing tool may be transmitted to a session in the
527
+ * workspace `ownSlug`. Non-MCP tools and global/manual MCP servers
528
+ * (one-segment serverName, e.g. `mcp__everything-search__search`) always
529
+ * pass; project MCP tools (`mcp__<workspace>__<name>__<tool>`) pass only for
530
+ * the session's OWN workspace — other live workspaces' tools stay out.
531
+ */
532
+ toolVisibleTo(name, ownSlug) {
533
+ if (typeof name !== "string" || !name.startsWith("mcp__")) return true;
534
+ const rest = name.slice("mcp__".length);
535
+ const sep = rest.indexOf("__");
536
+ if (sep <= 0) return true; // malformed / one-segment: treat as global
537
+ const first = rest.slice(0, sep);
538
+ if (!this.workspaceSlugSet?.has(first)) return true; // global or manual server
539
+ return first === ownSlug; // project tool: only the session's own workspace
540
+ }
541
+
542
+ /** Live loader entries that mount an MCP client, keyed by serverName. */
543
+ loaderEntriesByServer() {
544
+ const result = new Map();
545
+ for (const entry of this.ctx.loader.entries()) {
546
+ if (entry.options?.name !== MCP_CLIENT) continue;
547
+ const serverName = entry.options?.config?.serverName;
548
+ if (typeof serverName === "string") result.set(serverName, entry);
549
+ }
550
+ return result;
551
+ }
552
+
553
+ /** One workspace's disabled server names (from the mcp settings namespace). */
554
+ disabledSetFor(path) {
555
+ const value = this.state;
556
+ const projects = value?.projects;
557
+ if (!Array.isArray(projects)) return new Set();
558
+ const key = normKey(path);
559
+ const row = projects.find((candidate) => candidate?.path && normKey(candidate.path) === key);
560
+ return new Set(Array.isArray(row?.disabled) ? row.disabled : []);
561
+ }
562
+
563
+ /** One workspace's disabled skill names (from the mcp settings namespace). */
564
+ skillDisabledSetFor(path) {
565
+ const value = this.state;
566
+ const projects = value?.projects;
567
+ if (!Array.isArray(projects)) return new Set();
568
+ const key = normKey(path);
569
+ const row = projects.find((candidate) => candidate?.path && normKey(candidate.path) === key);
570
+ return new Set(Array.isArray(row?.disabledSkills) ? row.disabledSkills : []);
571
+ }
572
+
573
+ /**
574
+ * Whether a skill is disabled for the workspace containing `cwd` —used by the
575
+ * model-facing skill catalog (dsh-tool-skill) so sessions only see enabled
576
+ * skills. Matches the row whose path is the longest normalized prefix of cwd.
577
+ */
578
+ isSkillDisabled(cwd, name) {
579
+ if (typeof cwd !== "string" || cwd.length === 0 || typeof name !== "string") return false;
580
+ const value = this.state;
581
+ const projects = value?.projects;
582
+ if (!Array.isArray(projects)) return false;
583
+ const target = normPath(cwd).toLowerCase();
584
+ let best = null;
585
+ let bestLen = -1;
586
+ for (const row of projects) {
587
+ if (!row?.path) continue;
588
+ const candidate = normPath(row.path).toLowerCase();
589
+ if (candidate.length > bestLen && (target === candidate || target.startsWith(candidate + "\\") || target.startsWith(candidate + "/"))) {
590
+ best = row;
591
+ bestLen = candidate.length;
592
+ }
593
+ }
594
+ if (best === null) return false;
595
+ return Array.isArray(best.disabledSkills) && best.disabledSkills.includes(name);
596
+ }
597
+
598
+ /**
599
+ * The plain .mcp.json name and the qualified runtime name of one project
600
+ * server (identity for globals/manual when no project row matches).
601
+ */
602
+ serverNamePair(path, name) {
603
+ const row = this.projectServersByPath.get(normKey(path));
604
+ if (row !== void 0) {
605
+ for (const [plain, config] of row.servers) {
606
+ const runtime = config.serverName ?? plain;
607
+ if (plain === name) return { plain, runtime };
608
+ if (runtime === name) return { plain, runtime };
609
+ }
610
+ }
611
+ return { plain: name, runtime: name };
612
+ }
613
+
614
+ /** Toggle one MCP server or skill for one workspace, persist, and live-apply. */
615
+ async set(path, kind, name, enabled) {
616
+ if (typeof path !== "string" || path.length === 0) throw new TypeError("set: path is required");
617
+ if (kind !== "mcp" && kind !== "skill") throw new TypeError(`set: kind must be "mcp" or "skill", got ${JSON.stringify(kind)}`);
618
+ if (typeof name !== "string" || name.length === 0) throw new TypeError("set: name is required");
619
+ const key = normKey(path);
620
+ const value = this.state;
621
+ const projects = Array.isArray(value?.projects) ? value.projects.map((row) => ({ ...row })) : [];
622
+ const index = projects.findIndex((row) => row?.path && normKey(row.path) === key);
623
+ const row = index >= 0 ? projects[index] : { path: normPath(path), disabled: [], disabledSkills: [] };
624
+ const field = kind === "mcp" ? "disabled" : "disabledSkills";
625
+ const list = new Set(Array.isArray(row[field]) ? row[field] : []);
626
+ if (kind === "mcp") {
627
+ // Store the qualified runtime identity; enabling clears BOTH the legacy
628
+ // plain name and the runtime name (existing settings keep working).
629
+ const { plain, runtime } = this.serverNamePair(path, name);
630
+ if (enabled) {
631
+ list.delete(plain);
632
+ list.delete(runtime);
633
+ } else {
634
+ list.add(runtime);
635
+ }
636
+ } else if (enabled) {
637
+ list.delete(name);
638
+ } else {
639
+ list.add(name);
640
+ }
641
+ row[field] = [...list].sort();
642
+ const nextProjects = index >= 0 ? projects.map((entry, i) => (i === index ? row : entry)) : [...projects, row];
643
+ this.state = { projects: nextProjects };
644
+ this.persistState();
645
+ if (kind === "mcp") await this.applyState();
646
+ return { ok: true };
647
+ }
648
+
649
+ /**
650
+ * Workspaces that currently have at least one live session (from the host
651
+ * `sessions` service, via each session's header.cwd). Falls back to the
652
+ * focused/launch workspace so MCPs are available before any session opens.
653
+ */
654
+ liveWorkspaceKeys() {
655
+ const keys = new Set();
656
+ try {
657
+ const sessions = this.ctx.get("sessions")?.list?.() ?? [];
658
+ for (const session of sessions) {
659
+ const cwd = session?.header?.cwd;
660
+ if (typeof cwd !== "string" || cwd.length === 0) continue;
661
+ const path = this.workspacePathForCwd(cwd);
662
+ if (path !== null) keys.add(normKey(path));
663
+ }
664
+ } catch {
665
+ /* sessions service optional */
666
+ }
667
+ if (keys.size === 0) keys.add(normKey(this.activePath));
668
+ return keys;
669
+ }
670
+
671
+ /** The known workspace whose path is the longest normalized prefix of cwd. */
672
+ workspacePathForCwd(cwd) {
673
+ const target = normPath(cwd).toLowerCase();
674
+ let best = null;
675
+ let bestLen = -1;
676
+ for (const [key, path] of this.workspacePaths) {
677
+ if (key.length > bestLen && (target === key || target.startsWith(key + "\\") || target.startsWith(key + "/"))) {
678
+ best = path;
679
+ bestLen = key.length;
680
+ }
681
+ }
682
+ return best;
683
+ }
684
+
685
+ /** Display path for a workspace key, when known. */
686
+ workspacePathForKey(key) {
687
+ return this.workspacePaths.get(key) ?? this.projectServersByPath.get(key)?.path ?? null;
688
+ }
689
+
690
+ /**
691
+ * The union mount set: every server any LIVE workspace wants, keyed by its
692
+ * runtime serverName (globally unique — colliding project names are
693
+ * workspace-qualified, so several workspaces' same-named servers coexist as
694
+ * separate processes). Parallel sessions in different workspaces therefore
695
+ * never stop each other's MCPs.
696
+ *
697
+ * - globals: one shared process, mounted when ANY live workspace enables it.
698
+ * - project servers: one process per (workspace, server), each with its own
699
+ * config/cwd, mounted unless the owning workspace disables it.
700
+ */
701
+ unionMountSet(liveKeys) {
702
+ const set = new Map();
703
+ for (const [serverName, config] of this.globalServers) {
704
+ let wanted = false;
705
+ for (const key of liveKeys) {
706
+ const path = this.workspacePathForKey(key);
707
+ if (path !== null && !this.disabledSetFor(path).has(serverName)) {
708
+ wanted = true;
709
+ break;
710
+ }
711
+ }
712
+ if (wanted) set.set(serverName, config);
713
+ }
714
+ for (const key of liveKeys) {
715
+ const row = this.projectServersByPath.get(key);
716
+ if (row === void 0) continue;
717
+ const disabled = this.disabledSetFor(row.path);
718
+ for (const [serverName, config] of row.servers) {
719
+ const runtimeName = config.serverName ?? serverName;
720
+ // Honor BOTH the qualified identity (new toggles) and the legacy plain
721
+ // name (existing settings) so no migration is needed.
722
+ if (disabled.has(runtimeName) || disabled.has(serverName)) continue;
723
+ set.set(runtimeName, config);
724
+ }
725
+ }
726
+ return set;
727
+ }
728
+
729
+ /** One serialized apply pass: reconcile every server against the disabled set. */
730
+ applyState() {
731
+ this.applyQueue = this.applyQueue.then(async () => {
732
+ try {
733
+ await this.applyStateNow();
734
+ } catch (error) {
735
+ this.ctx.logger?.warn(`dsh-mcp-manager: applyState failed: ${String(error)}`);
736
+ }
737
+ });
738
+ return this.applyQueue;
739
+ }
740
+
741
+ async applyStateNow() {
742
+ const liveKeys = this.liveWorkspaceKeys();
743
+ const mountSet = this.unionMountSet(liveKeys);
744
+
745
+ // 1) Manager-mounted fibers: unmount anything no live workspace wants;
746
+ // sweep orphaned child processes after.
747
+ for (const serverName of [...this.fibers.keys()]) {
748
+ if (mountSet.has(serverName)) continue;
749
+ const fiber = this.fibers.get(serverName);
750
+ const config = this.fiberConfigs.get(serverName);
751
+ this.fibers.delete(serverName);
752
+ this.fiberConfigs.delete(serverName);
753
+ try {
754
+ await fiber?.dispose();
755
+ } catch (error) {
756
+ this.ctx.logger?.warn(`dsh-mcp-manager: unmount ${serverName} failed: ${String(error)}`);
757
+ }
758
+ const marker = markerFor(config);
759
+ if (marker !== null) scheduleOrphanSweep(marker);
760
+ }
761
+
762
+ // 2) Mount what live workspaces want and is not yet mounted.
763
+ // ctx.plugin() reads dependency metadata from `plugin.inject`, so the
764
+ // bare apply function is NOT enough: mcp-client injects the "tools"
765
+ // service, and without it every registration fails with
766
+ // "cannot get property \"tools\" without inject" (tools silently never
767
+ // registered while the connection stays up). Pass the full plugin
768
+ // object so Cordis resolves the injected services.
769
+ const { apply: mcpClientApply, inject: mcpClientInject, Config: mcpClientConfig, name: mcpClientName } = await import(MCP_CLIENT);
770
+ for (const [serverName, config] of mountSet) {
771
+ if (this.fibers.has(serverName)) continue;
772
+ try {
773
+ const fiber = await this.ctx.plugin({
774
+ apply: mcpClientApply,
775
+ inject: mcpClientInject,
776
+ Config: mcpClientConfig,
777
+ name: mcpClientName,
778
+ }, config);
779
+ this.fibers.set(serverName, fiber);
780
+ this.fiberConfigs.set(serverName, config);
781
+ } catch (error) {
782
+ this.ctx.logger?.warn(`dsh-mcp-manager: mount ${config?.serverName ?? serverName} failed: ${String(error)}`);
783
+ }
784
+ }
785
+
786
+ // 3) Manual loader entries: keep mounted while ANY live workspace wants
787
+ // them (a shared process cannot be per-workspace); disable only when
788
+ // every live workspace disables the server (or none is live).
789
+ for (const [serverName, entry] of this.loaderEntriesByServer()) {
790
+ if (this.globalServers.has(serverName)) continue; // global now manager-owned
791
+ let wanted = false;
792
+ for (const key of liveKeys) {
793
+ const path = this.workspacePathForKey(key);
794
+ if (path !== null && !this.disabledSetFor(path).has(serverName)) {
795
+ wanted = true;
796
+ break;
797
+ }
798
+ }
799
+ const shouldDisable = !wanted;
800
+ if (entry.disabled !== shouldDisable) {
801
+ try {
802
+ await entry.update({ disabled: shouldDisable });
803
+ } catch (error) {
804
+ this.ctx.logger?.warn(`dsh-mcp-manager: ${shouldDisable ? "disable" : "enable"} ${serverName} failed: ${String(error)}`);
805
+ }
806
+ if (shouldDisable) {
807
+ const marker = markerFor(entry.options?.config);
808
+ if (marker !== null) scheduleOrphanSweep(marker);
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Remember the focused workspace (display + no-session fallback). Mounting is
816
+ * driven by the union of live workspaces (session events), so switching the
817
+ * focused session does NOT stop another workspace's MCPs; the reconcile here
818
+ * only matters for the no-session fallback path.
819
+ */
820
+ async activate(path) {
821
+ if (typeof path !== "string" || path.trim().length === 0) return this.activePath;
822
+ const next = normPath(path);
823
+ if (normKey(next) === normKey(this.activePath)) return this.activePath;
824
+ this.activePath = next;
825
+ this.ctx.logger?.info(`dsh-mcp-manager: active workspace -> ${next}`);
826
+ await this.applyState();
827
+ return this.activePath;
828
+ }
829
+
830
+ /** Compose the inventory for one display workspace (does NOT mutate the active one). */
831
+ async inventory(displayPath) {
832
+ const context = displayPath ? normPath(displayPath) : this.activePath;
833
+ const loaderEntries = this.loaderEntriesByServer();
834
+ const disabled = this.disabledSetFor(context);
835
+ const global = [];
836
+ for (const [serverName, config] of this.globalServers) {
837
+ global.push({
838
+ serverName,
839
+ transport: config.transport ?? "stdio",
840
+ command: typeof config.command === "string" ? config.command : config.url ?? "",
841
+ enabled: !disabled.has(serverName),
842
+ mounted: this.fibers.has(serverName),
843
+ });
844
+ }
845
+ global.sort((a, b) => a.serverName.localeCompare(b.serverName));
846
+
847
+ const manual = [];
848
+ for (const [serverName, entry] of loaderEntries) {
849
+ if (this.globalServers.has(serverName)) continue;
850
+ const config = entry.options?.config ?? {};
851
+ manual.push({
852
+ serverName,
853
+ transport: config.transport ?? "stdio",
854
+ command: typeof config.command === "string" ? config.command : config.url ?? "",
855
+ enabled: !disabled.has(serverName),
856
+ mounted: !entry.disabled,
857
+ });
858
+ }
859
+ manual.sort((a, b) => a.serverName.localeCompare(b.serverName));
860
+
861
+ const projects = [];
862
+ for (const [key, { path, servers: map }] of this.projectServersByPath) {
863
+ const isContext = key === normKey(context);
864
+ const pDisabled = this.disabledSetFor(path);
865
+ const rows = [];
866
+ for (const [serverName, config] of map) {
867
+ // Project rows expose their runtime identity (workspace-qualified for
868
+ // colliding names like `codebase-xxsbm`); toggles and the mounted flag
869
+ // use that identity.
870
+ const runtimeName = config.serverName ?? serverName;
871
+ rows.push({
872
+ serverName: runtimeName,
873
+ rawName: serverName,
874
+ transport: config.transport,
875
+ command: typeof config.command === "string" ? config.command : config.url ?? "",
876
+ enabled: !(pDisabled.has(runtimeName) || pDisabled.has(serverName)),
877
+ mounted: isContext && this.fibers.has(runtimeName),
878
+ });
879
+ }
880
+ rows.sort((a, b) => a.serverName.localeCompare(b.serverName));
881
+ projects.push({ path, current: isContext, servers: rows });
882
+ }
883
+ projects.sort((a, b) => (a.current ? -1 : 0) - (b.current ? -1 : 0) || a.path.localeCompare(b.path));
884
+
885
+ // Skills visible to this workspace (unfiltered — disabled ones stay listed
886
+ // so they can be re-enabled), with per-workspace enabled state. Discovered
887
+ // directly from the same roots the per-agent skill-filesystem uses (the web
888
+ // profile keeps the host skill provider disabled; presets own discovery).
889
+ const skillDisabled = this.skillDisabledSetFor(context);
890
+ const skills = (await this.discoverSkills(context)).map((skill) => ({
891
+ name: skill.name,
892
+ description: skill.description,
893
+ source: skill.source,
894
+ // The popup groups project-scoped and globally-scoped skills separately;
895
+ // the host decides which is which so the client never parses source names.
896
+ scope: String(skill.source ?? "").startsWith("project-") ? "project" : "global",
897
+ enabled: !skillDisabled.has(skill.name),
898
+ }));
899
+ skills.sort((a, b) => String(a.name).localeCompare(String(b.name)));
900
+
901
+ return {
902
+ projectPath: this.projectPath,
903
+ currentProject: context,
904
+ activeWorkspace: this.activePath,
905
+ writable: true,
906
+ disabled: [...disabled].sort(),
907
+ global,
908
+ globalSource: this.globalSource,
909
+ manual,
910
+ projects,
911
+ skills,
912
+ };
913
+ }
914
+
915
+ /** Skill roots mirroring dsh-skill-filesystem's defaults for one workspace. */
916
+ skillRoots(workspacePath) {
917
+ return [
918
+ { path: join(workspacePath, ".dsh", "skills"), source: "project-dsh" },
919
+ { path: join(workspacePath, ".claude", "skills"), source: "project-claude" },
920
+ { path: join(workspacePath, ".agents", "skills"), source: "project-agents" },
921
+ { path: join(dshHome(), "skills"), source: "user-dsh" },
922
+ { path: join(homedir(), ".claude", "skills"), source: "user-claude" },
923
+ { path: join(this.agentsHome(), "skills"), source: "user-agents" },
924
+ ];
925
+ }
926
+
927
+ agentsHome() {
928
+ return process.env.DSH_AGENTS_HOME || join(homedir(), ".agents");
929
+ }
930
+
931
+ /** Discover skills for one workspace (name/description from SKILL.md frontmatter). */
932
+ async discoverSkills(workspacePath) {
933
+ const seen = new Map();
934
+ for (const root of this.skillRoots(workspacePath)) {
935
+ let entries;
936
+ try {
937
+ entries = await readdirWithTypes(root.path);
938
+ } catch {
939
+ continue; // absent root
940
+ }
941
+ for (const entry of entries) {
942
+ let skillPath;
943
+ if (entry.isDirectory()) skillPath = join(root.path, entry.name, "SKILL.md");
944
+ else if (entry.isFile() && entry.name.endsWith(".md")) skillPath = join(root.path, entry.name);
945
+ else continue;
946
+ const parsed = parseSkillFile(skillPath);
947
+ if (parsed === null || seen.has(parsed.name)) continue;
948
+ seen.set(parsed.name, { ...parsed, source: root.source });
949
+ }
950
+ }
951
+ return [...seen.values()];
952
+ }
953
+
954
+ registerRoutes() {
955
+ this.disposers.push(
956
+ this.ctx.webServer.register({
957
+ kind: "exact",
958
+ path: INVENTORY_PATH,
959
+ handler: async (req, res) => {
960
+ try {
961
+ const query = new URL(req.url ?? "/", "http://localhost").searchParams;
962
+ const active = query.get("active");
963
+ this.rescanProjects(); // fresh workspace + .mcp.json state on every open
964
+ sendJson(res, 200, await this.inventory(active ?? void 0));
965
+ } catch (error) {
966
+ sendJson(res, 500, { ok: false, error: String(error) });
967
+ }
968
+ },
969
+ })
970
+ );
971
+ this.disposers.push(
972
+ this.ctx.webServer.register({
973
+ kind: "exact",
974
+ path: ACTIVATE_PATH,
975
+ handler: async (req, res) => {
976
+ try {
977
+ const body = await readBody(req);
978
+ this.rescanProjects();
979
+ const active = await this.activate(body?.path);
980
+ sendJson(res, 200, { ok: true, currentProject: active });
981
+ } catch (error) {
982
+ sendJson(res, 400, { ok: false, error: String(error) });
983
+ }
984
+ },
985
+ })
986
+ );
987
+ this.disposers.push(
988
+ this.ctx.webServer.register({
989
+ kind: "exact",
990
+ path: SET_PATH,
991
+ handler: async (req, res) => {
992
+ try {
993
+ const body = await readBody(req);
994
+ const result = await this.set(body?.path, body?.kind, body?.name, body?.enabled === true);
995
+ sendJson(res, 200, result);
996
+ } catch (error) {
997
+ sendJson(res, 400, { ok: false, error: String(error) });
998
+ }
999
+ },
1000
+ })
1001
+ );
1002
+ }
1003
+
1004
+ registerSettings() {
1005
+ // Prefer this plugin's own state file; seed it once from the legacy `mcp`
1006
+ // settings section so existing per-workspace toggles survive the upgrade.
1007
+ let loaded = null;
1008
+ try {
1009
+ if (existsSync(statePath())) loaded = normalizeState(JSON.parse(readFileSync(statePath(), "utf8")));
1010
+ } catch (error) {
1011
+ this.ctx.logger?.warn(`dsh-mcp-manager: unreadable state file: ${String(error)}`);
1012
+ }
1013
+ if (loaded === null) {
1014
+ loaded = normalizeState(readLegacySection() ?? { projects: [] });
1015
+ this.state = loaded;
1016
+ try {
1017
+ this.persistState();
1018
+ } catch (error) {
1019
+ this.ctx.logger?.warn(`dsh-mcp-manager: could not seed state file: ${String(error)}`);
1020
+ }
1021
+ return;
1022
+ }
1023
+ this.state = loaded;
1024
+ }
1025
+
1026
+ /** Write the current in-memory state to the plugin's own state file. */
1027
+ persistState() {
1028
+ const target = statePath();
1029
+ mkdirSync(dirname(target), { recursive: true });
1030
+ writeFileSync(target, JSON.stringify(this.state, null, 2) + "\n", "utf8");
1031
+ }
1032
+
1033
+ async start() {
1034
+ this.loadGlobal();
1035
+ this.rescanProjects();
1036
+ this.registerSettings();
1037
+ this.registerRoutes();
1038
+ // Union mounting follows LIVE sessions: opening/closing a session mounts/
1039
+ // unmounts that workspace's MCP set without disturbing other workspaces'.
1040
+ // Events bubble to the app root, so listen there (the manager's context is
1041
+ // a sibling of the sessions service).
1042
+ const onRoot = (event, handler) => {
1043
+ const off = this.ctx.root?.on?.(event, handler);
1044
+ if (typeof off === "function") this.disposers.push(off);
1045
+ };
1046
+ onRoot("session/created", () => this.applyState());
1047
+ onRoot("session/disposed", () => this.applyState());
1048
+ // Per-session MCP tool filter: a session only ever receives its OWN
1049
+ // workspace's project MCP tools (mcp__<ownWorkspace>__*) plus globals and
1050
+ // manual entries — never another live workspace's (mcp__<other>__*).
1051
+ onRoot("system-prompt/assemble", async (assembly, context, next) => {
1052
+ const assembled = await next();
1053
+ const cwd = context?.scope?.session?.header?.cwd;
1054
+ const slug = typeof cwd === "string" && cwd.length > 0 ? this.slugForCwd(cwd) : null;
1055
+ if (slug === null || this.workspaceSlugSet?.size === 0) return assembled;
1056
+ assembled.tools = assembled.tools.filter((tool) => this.toolVisibleTo(tool.name, slug));
1057
+ return assembled;
1058
+ });
1059
+ this.ctx.effect(() => () => {
1060
+ for (const dispose of this.disposers.splice(0)) {
1061
+ try {
1062
+ dispose();
1063
+ } catch {
1064
+ /* disposal best effort */
1065
+ }
1066
+ }
1067
+ for (const [serverName, fiber] of this.fibers) {
1068
+ fiber.dispose().catch(() => {});
1069
+ const marker = markerFor(this.fiberConfigs.get(serverName));
1070
+ if (marker !== null) scheduleOrphanSweep(marker);
1071
+ }
1072
+ this.fibers.clear();
1073
+ this.fiberConfigs.clear();
1074
+ }, "dsh-mcp-manager: teardown");
1075
+ await this.applyState();
1076
+ }
1077
+ }
1078
+
1079
+ export async function apply(ctx) {
1080
+ const manager = new McpManager(ctx);
1081
+ ctx.provide("mcpManager", manager);
1082
+ // Claude-Code skill roots (see lib/skill-roots.js): a nested inject() fiber
1083
+ // keeps the MCP manager itself independent of profiles that do not mount the
1084
+ // skill registry.
1085
+ ctx.inject(["skills"], function claudeSkillRoots(skillsCtx) {
1086
+ registerClaudeSkillRoots(skillsCtx);
1087
+ });
1088
+ // Per-workspace skill filtering (see lib/skill-filter.js): the 0.2.x
1089
+ // replacement for the old dsh-tool-skill core patch. Installed from an
1090
+ // injected fiber for the same reason, and owned by that fiber so disposing
1091
+ // the plugin restores the original service methods.
1092
+ ctx.inject(["skills"], function mcpManagerSkillFilter(skillsCtx) {
1093
+ skillsCtx.effect(() => installSkillFilter(manager, skillsCtx), "dsh-mcp-manager: skill filter");
1094
+ });
1095
+ await manager.start();
1096
+ ctx.logger?.info(
1097
+ `dsh-mcp-manager: active=${manager.activePath} global=${manager.globalServers.size} projects=${manager.projectServersByPath.size}`
1098
+ );
1099
+ }