@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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +278 -2
- package/README.zh.md +263 -0
- package/client/client.js +535 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +11 -0
- package/lib/index.js +1099 -0
- package/lib/skill-filter.js +87 -0
- package/lib/skill-roots.js +518 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
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
|
+
}
|