dsh-project-mcp-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,637 @@
1
+ /**
2
+ * dsh-project-mcp-manager —— 项目级 MCP 装载与按会话可见性。
3
+ *
4
+ * 项目级 MCP 服务器配置存放在 <projectRoot>/.dsh/mcp.yml(与技能侧的
5
+ * <projectRoot>/.dsh/skills/ 并列),文件格式与 profile cordis.patch.yml
6
+ * 受管块一致(begin/end 标记之间的 YAML insert 列表)。
7
+ *
8
+ * 装载模型:
9
+ * - 每个 (项目, serverName) 在宿主 ctx 上装载一个 @deepseek-ai/dsh-mcp-client
10
+ * 实例(ctx.plugin),注册进全局工具层——同一项目内多会话共享同一连接;
11
+ * - 生效名:原始 serverName 在整个目录(全局行 + 全部项目行)中唯一时保持
12
+ * 原名;否则按 model.effectiveServerNames 规则改名(确定性、与装载顺序
13
+ * 无关),避免 dsh-mcp-client 按进程根的 serverName 预留冲突;
14
+ * - 会话可见性:agent 创建时按其会话 cwd 解析项目,对该 agent 层应用
15
+ * tools.restrict({ deny }),deny 掉「除本会话项目外的全部项目服务器」;
16
+ * 目录变化(项目文件增删改、新 agent、装载 settle)都会重扫。
17
+ *
18
+ * 子代理(subagent)不继承父 agent 层的 restrict,但本注册表对每个 live
19
+ * agent(含子代理)都按各自会话 cwd 应用同样的 deny 规则,因此行为一致。
20
+ * 会话无 cwd 且无 owner 时回退 dsh 进程 cwd 所在项目(“在该项目开启 dsh
21
+ * 会话”场景)。
22
+ */
23
+ import chokidar from "chokidar";
24
+ import { join, resolve } from "node:path";
25
+ import { readFile, writeFile } from "node:fs/promises";
26
+ import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
27
+ import { extractManagedRows, readPatchFile } from "./mcp-file.js";
28
+ import { findProjectRoot } from "./project-root.js";
29
+ import { denySetFor, effectiveServerNames, inputFromPatchRow, patchRowToView, projectKeyOf, serverNameFromRowId, toOfficialConfig } from "./model.js";
30
+ import { mcpToolCount } from "./status.js";
31
+ const delay = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
32
+ /** 项目 MCP 配置文件名(位于项目根下)。 */
33
+ export const PROJECT_MCP_FILE = "mcp.yml";
34
+ /** 面板状态 → wire fiberPhase 词汇(wire 无 "mounting")。 */
35
+ export function phaseToFiberPhase(phase) {
36
+ switch (phase) {
37
+ case "mounting": return "loading";
38
+ case "active": return "active";
39
+ case "failed": return "failed";
40
+ case "unloading": return "unloading";
41
+ }
42
+ }
43
+ /** 行配置的规范化序列化(键排序),用于判断行是否实质变化。 */
44
+ function canonicalConfig(config) {
45
+ return JSON.stringify(config ?? null, (key, value) => {
46
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
47
+ return Object.keys(value).sort().reduce((acc, k) => {
48
+ acc[k] = value[k];
49
+ return acc;
50
+ }, {});
51
+ }
52
+ return value;
53
+ });
54
+ }
55
+ /** 行的原始 serverName(受管行 id 或 config.serverName)。 */
56
+ export function rowNameOf(row) {
57
+ const fromId = serverNameFromRowId(row.id);
58
+ if (fromId !== undefined)
59
+ return fromId;
60
+ return typeof row.config?.serverName === "string" ? row.config.serverName : undefined;
61
+ }
62
+ /**
63
+ * 计算一个项目应当执行的装载变更(纯函数):
64
+ * toUnmount —— 已装载但已不存在 / 生效名变化 / 配置变化的 rawName;
65
+ * toMount —— 尚未装载或需要按新行/新生效名重新装载的条目。
66
+ */
67
+ export function planProjectChanges(current, desired, effective, projectKey) {
68
+ const toUnmount = [];
69
+ const toMount = [];
70
+ const desiredByName = new Map(desired.map((item) => [item.rawName, item]));
71
+ for (const state of current) {
72
+ const want = desiredByName.get(state.rawName);
73
+ if (want === undefined) {
74
+ toUnmount.push(state.rawName);
75
+ continue;
76
+ }
77
+ const eff = effective.get(projectKey + "\u0000" + state.rawName);
78
+ if (eff !== state.effectiveName || canonicalConfig(state.row.config) !== canonicalConfig(want.row.config)) {
79
+ toUnmount.push(state.rawName);
80
+ }
81
+ }
82
+ for (const item of desired) {
83
+ const state = current.find((candidate) => candidate.rawName === item.rawName);
84
+ if (state === undefined) {
85
+ toMount.push(item);
86
+ continue;
87
+ }
88
+ const eff = effective.get(projectKey + "\u0000" + item.rawName);
89
+ if (eff !== state.effectiveName || canonicalConfig(state.row.config) !== canonicalConfig(item.row.config)) {
90
+ toMount.push(item);
91
+ }
92
+ }
93
+ return { toUnmount, toMount };
94
+ }
95
+ /** 项目文件绝对路径。 */
96
+ export function projectMcpFile(projectRoot) {
97
+ return join(projectRoot, ".dsh", PROJECT_MCP_FILE);
98
+ }
99
+ /**
100
+ * 项目级 MCP 注册表:文件监听、装载/卸载、按会话 deny 重扫、状态快照。
101
+ */
102
+ export class ProjectMcpRegistry {
103
+ ctx;
104
+ providers;
105
+ /** projectKey → 项目条目(含已装载服务器)。 */
106
+ projects = new Map();
107
+ /** projectKey → 最近一次生效名目录(key = projectKey + "\0" + rawName)。 */
108
+ effective = new Map();
109
+ /** agent id → 其会话项目 key(undefined 表示无项目)。 */
110
+ agentProjects = new Map();
111
+ /** agent id → 当前 restrict 的 disposer。 */
112
+ restrictions = new Map();
113
+ watcher;
114
+ watchedFiles = [];
115
+ timer;
116
+ chain = Promise.resolve();
117
+ disposed = false;
118
+ constructor(ctx, providers) {
119
+ this.ctx = ctx;
120
+ this.providers = providers;
121
+ ctx.on("agent/created", ({ agent }) => {
122
+ if (agent === undefined)
123
+ return;
124
+ this.enqueue(async () => {
125
+ this.agentProjects.set(agent.id, await this.resolveProject(agent));
126
+ await this.reconcileAll();
127
+ });
128
+ });
129
+ ctx.on("agent/disposed", ({ agent }) => {
130
+ if (agent === undefined)
131
+ return;
132
+ this.releaseAgent(agent);
133
+ });
134
+ // 会话生命周期开始(含恢复/重挂的会话)也补扫一次,覆盖启动时序缺口。
135
+ ctx.on("agent/session-start", ({ agent }) => {
136
+ if (agent === undefined)
137
+ return;
138
+ this.enqueue(async () => {
139
+ this.agentProjects.set(agent.id, await this.resolveProject(agent));
140
+ await this.reconcileAll();
141
+ });
142
+ });
143
+ // 插件热更重载时已存在的会话也要覆盖。
144
+ this.enqueue(async () => {
145
+ for (const agent of this.liveAgents()) {
146
+ this.agentProjects.set(agent.id, await this.resolveProject(agent));
147
+ }
148
+ await this.reconcileAll();
149
+ });
150
+ ctx.effect(() => () => {
151
+ this.disposed = true;
152
+ if (this.timer !== undefined)
153
+ clearTimeout(this.timer);
154
+ if (this.watcher !== undefined)
155
+ void this.watcher.close().catch(() => { });
156
+ for (const disposer of this.restrictions.values()) {
157
+ try {
158
+ disposer();
159
+ }
160
+ catch {
161
+ // agent 已销毁时 disposer 可能已失效
162
+ }
163
+ }
164
+ this.restrictions.clear();
165
+ for (const entry of this.projects.values()) {
166
+ for (const state of entry.servers.values()) {
167
+ try {
168
+ state.fiber?.dispose();
169
+ }
170
+ catch {
171
+ // fiber 随插件 ctx 一并销毁
172
+ }
173
+ }
174
+ }
175
+ this.projects.clear();
176
+ }, "dsh-project-mcp-manager: project mcp registry");
177
+ }
178
+ // ── 串行化与调度 ─────────────────────────────────────────────────────
179
+ enqueue(work) {
180
+ const run = this.chain.then(work, work);
181
+ this.chain = run.then(() => undefined, () => undefined);
182
+ return run;
183
+ }
184
+ kick() {
185
+ if (this.timer !== undefined)
186
+ return;
187
+ this.timer = setTimeout(() => {
188
+ this.timer = undefined;
189
+ this.enqueue(async () => {
190
+ await this.reconcileAll();
191
+ }).catch(() => { });
192
+ }, 150);
193
+ }
194
+ liveAgents() {
195
+ try {
196
+ const list = this.ctx.agents?.list?.();
197
+ return Array.isArray(list) ? list : [];
198
+ }
199
+ catch {
200
+ return [];
201
+ }
202
+ }
203
+ // ── 项目发现与文件监听 ──────────────────────────────────────────────
204
+ async knownProjects() {
205
+ const map = new Map();
206
+ const add = async (raw) => {
207
+ if (typeof raw !== "string" || raw === "")
208
+ return;
209
+ try {
210
+ const project = await findProjectRoot(raw);
211
+ map.set(projectKeyOf(project), project);
212
+ }
213
+ catch {
214
+ // 目录不可解析:忽略
215
+ }
216
+ };
217
+ // 项目发现:在线会话 cwd + 进程 cwd(dsh 启动目录)+ 已装载项目。
218
+ for (const agent of this.liveAgents()) {
219
+ const cwd = agent?.session?.header?.cwd;
220
+ if (typeof cwd === "string" && cwd !== "")
221
+ await add(cwd);
222
+ }
223
+ try {
224
+ await add(process.cwd());
225
+ }
226
+ catch {
227
+ // 启动目录不可解析:忽略
228
+ }
229
+ for (const [key, entry] of this.projects)
230
+ map.set(key, entry.projectRoot);
231
+ return [...map.values()];
232
+ }
233
+ async syncWatcher() {
234
+ const roots = await this.knownProjects();
235
+ const keys = roots.map((root) => projectKeyOf(root)).sort();
236
+ const same = this.watchedFiles.length === keys.length && keys.every((key, index) => key === this.watchedFiles[index]);
237
+ if (same)
238
+ return;
239
+ const old = this.watcher;
240
+ this.watcher = undefined;
241
+ if (old !== undefined)
242
+ await old.close().catch(() => { });
243
+ this.watchedFiles = keys;
244
+ if (keys.length === 0)
245
+ return;
246
+ // chokidar 不会监听尚不存在的嵌套文件:改为监听项目根(depth 2 覆盖
247
+ // .dsh/mcp.yml),事件回调里按精确路径过滤。
248
+ const watcher = chokidar.watch(roots, {
249
+ ignoreInitial: true,
250
+ depth: 2,
251
+ ignored: (candidate) => {
252
+ const parts = candidate.split(/[/\\]/);
253
+ return parts.some((part) => part === "node_modules" || part === ".git" || part === ".hg" || part === ".svn");
254
+ }
255
+ });
256
+ const kick = (path) => {
257
+ const parts = String(path ?? "").split(/[/\\]/).filter(Boolean);
258
+ if (parts.length < 2 || parts[parts.length - 1] !== PROJECT_MCP_FILE || parts[parts.length - 2] !== ".dsh")
259
+ return;
260
+ this.kick();
261
+ };
262
+ watcher.on("add", kick);
263
+ watcher.on("change", kick);
264
+ watcher.on("unlink", kick);
265
+ watcher.on("error", () => {
266
+ // 保持监听;下一次事件仍会触发 reconcile
267
+ });
268
+ this.watcher = watcher;
269
+ }
270
+ // ── 核心 reconcile ───────────────────────────────────────────────────
271
+ /**
272
+ * 全量对账:重算项目集合 → 读全部项目文件 → 计算生效名 → 逐项目装载/
273
+ * 卸载 → 重扫各会话 deny。文件事件、新 agent、插件热更都汇到这里。
274
+ */
275
+ async reconcileAll() {
276
+ if (this.disposed)
277
+ return;
278
+ await this.syncWatcher();
279
+ const roots = await this.knownProjects();
280
+ const desiredByProject = new Map();
281
+ for (const projectRoot of roots) {
282
+ const key = projectKeyOf(projectRoot);
283
+ const path = projectMcpFile(projectRoot);
284
+ let rows = [];
285
+ let ok = true;
286
+ let error = null;
287
+ try {
288
+ const raw = await readPatchFile(path);
289
+ for (const row of extractManagedRows(raw)) {
290
+ const rawName = rowNameOf(row);
291
+ if (rawName === undefined)
292
+ continue;
293
+ if (row.disabled === true)
294
+ continue;
295
+ rows.push({ rawName, row });
296
+ }
297
+ }
298
+ catch (catchError) {
299
+ if (this.projects.has(key) || !String(catchError).includes("ENOENT")) {
300
+ ok = false;
301
+ error = catchError instanceof Error ? catchError.message : String(catchError);
302
+ }
303
+ rows = [];
304
+ }
305
+ desiredByProject.set(key, { projectRoot, rows, ok, error });
306
+ await this.writeDiag(projectRoot, { kind: "scan", ok, error, rows: rows.map((row) => row.rawName) });
307
+ }
308
+ const catalogProjects = [...desiredByProject.values()]
309
+ .filter((entry) => entry.ok)
310
+ .map((entry) => ({ projectRoot: entry.projectRoot, names: entry.rows.map((row) => row.rawName) }));
311
+ const globalNames = await this.providers.globalNames().catch(() => []);
312
+ this.effective = effectiveServerNames(catalogProjects, globalNames);
313
+ for (const [key, entry] of desiredByProject) {
314
+ await this.reconcileProject(key, entry);
315
+ }
316
+ await this.sweepRestrictions();
317
+ }
318
+ async reconcileProject(key, entry) {
319
+ let project = this.projects.get(key);
320
+ if (project === undefined) {
321
+ project = { projectRoot: entry.projectRoot, servers: new Map() };
322
+ this.projects.set(key, project);
323
+ }
324
+ const current = [...project.servers.values()].map((state) => ({ rawName: state.rawName, effectiveName: state.effectiveName, row: state.row }));
325
+ const plan = planProjectChanges(current, entry.rows, this.effective, key);
326
+ // 先卸载(同名重装载必须先释放 serverName 预留),再装载。
327
+ for (const rawName of plan.toUnmount)
328
+ await this.unmountServer(project, rawName);
329
+ for (const item of plan.toMount)
330
+ await this.mountServer(project, item);
331
+ }
332
+ // ── 装载诊断(写 <projectRoot>/.dsh/.mcp-diag.json,宿主日志不可见时定位失败)──
333
+ async writeDiag(projectRoot, event) {
334
+ try {
335
+ const path = join(projectRoot, ".dsh", ".mcp-diag.json");
336
+ let lines = [];
337
+ try {
338
+ const parsed = JSON.parse(await readFile(path, "utf8"));
339
+ if (Array.isArray(parsed))
340
+ lines = parsed;
341
+ }
342
+ catch {
343
+ lines = [];
344
+ }
345
+ lines.push({ ts: new Date().toISOString(), ...event });
346
+ if (lines.length > 30)
347
+ lines = lines.slice(-30);
348
+ await writeFile(path, JSON.stringify(lines, null, 2), "utf8");
349
+ }
350
+ catch {
351
+ // 诊断失败不影响主流程
352
+ }
353
+ }
354
+ async mountServer(project, item) {
355
+ if (this.disposed)
356
+ return;
357
+ const key = projectKeyOf(project.projectRoot);
358
+ const effectiveName = this.effective.get(key + "\u0000" + item.rawName);
359
+ if (effectiveName === undefined)
360
+ return;
361
+ await this.writeDiag(project.projectRoot, { kind: "attempt", rawName: item.rawName, effectiveName });
362
+ let config;
363
+ try {
364
+ const input = inputFromPatchRow(item.row);
365
+ const configInput = { ...input, serverName: effectiveName };
366
+ if (input.transport === "stdio" && typeof input.cwd === "string" && input.cwd !== "") {
367
+ configInput.cwd = resolve(project.projectRoot, input.cwd);
368
+ }
369
+ config = toOfficialConfig(configInput);
370
+ }
371
+ catch (error) {
372
+ const message = error instanceof Error ? error.message : String(error);
373
+ await this.writeDiag(project.projectRoot, { kind: "config-invalid", rawName: item.rawName, error: message });
374
+ this.ctx.logger.warn(`项目 MCP "${item.rawName}"(${project.projectRoot})配置无效:${message}`);
375
+ return;
376
+ }
377
+ let fiber;
378
+ try {
379
+ fiber = this.ctx.plugin(mcpClient, config);
380
+ }
381
+ catch (error) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ await this.writeDiag(project.projectRoot, { kind: "plugin-throw", effectiveName, error: message });
384
+ this.ctx.logger.error(`项目 MCP "${effectiveName}"(${project.projectRoot})装载失败:${message}`);
385
+ return;
386
+ }
387
+ const state = {
388
+ projectRoot: project.projectRoot,
389
+ rawName: item.rawName,
390
+ effectiveName,
391
+ row: item.row,
392
+ fiber,
393
+ phase: "mounting"
394
+ };
395
+ project.servers.set(item.rawName, state);
396
+ fiber.then(() => {
397
+ if (this.projects.get(key) !== project || project.servers.get(item.rawName) !== state)
398
+ return;
399
+ state.phase = "active";
400
+ state.error = undefined;
401
+ void this.writeDiag(project.projectRoot, { kind: "active", effectiveName });
402
+ this.kickSweep();
403
+ }, (error) => {
404
+ if (this.projects.get(key) !== project || project.servers.get(item.rawName) !== state)
405
+ return;
406
+ state.phase = "failed";
407
+ state.error = error instanceof Error ? error.message : String(error);
408
+ void this.writeDiag(project.projectRoot, { kind: "failed", effectiveName, error: state.error });
409
+ this.ctx.logger.error(`项目 MCP "${effectiveName}"(${project.projectRoot})装载失败:${state.error}`);
410
+ this.kickSweep();
411
+ });
412
+ }
413
+ async unmountServer(project, rawName) {
414
+ const state = project.servers.get(rawName);
415
+ if (state === undefined)
416
+ return;
417
+ project.servers.delete(rawName);
418
+ state.phase = "unloading";
419
+ try {
420
+ await state.fiber?.dispose();
421
+ }
422
+ catch {
423
+ // fiber 已随上下文销毁
424
+ }
425
+ }
426
+ // ── 会话可见性(deny 重扫)────────────────────────────────────────────
427
+ kickSweep() {
428
+ if (this.disposed)
429
+ return;
430
+ this.enqueue(async () => {
431
+ await this.sweepRestrictions();
432
+ }).catch(() => { });
433
+ }
434
+ async sweepRestrictions() {
435
+ if (this.disposed)
436
+ return;
437
+ const groups = [];
438
+ for (const entry of this.projects.values()) {
439
+ const names = [];
440
+ for (const state of entry.servers.values()) {
441
+ if (state.phase !== "active")
442
+ continue;
443
+ names.push(state.effectiveName);
444
+ }
445
+ if (names.length > 0)
446
+ groups.push({ projectRoot: entry.projectRoot, effectiveNames: names });
447
+ }
448
+ // tools.restrict 的 deny 是精确工具名("unknown names fail"),不是 serverName;
449
+ // 把各服务器的 serverName 展开为它当前注册的全部工具名(mcp__<server>__*)。
450
+ let schemas = [];
451
+ try {
452
+ schemas = this.ctx.tools?.schemas?.() ?? [];
453
+ }
454
+ catch {
455
+ schemas = [];
456
+ }
457
+ const toolNamesOf = (serverName) => {
458
+ const prefix = "mcp__" + serverName + "__";
459
+ return schemas
460
+ .map((s) => String(s?.id ?? s?.name ?? ""))
461
+ .filter((name) => name.startsWith(prefix));
462
+ };
463
+ for (const agent of this.liveAgents()) {
464
+ const project = this.agentProjects.get(agent.id) ?? await this.resolveProject(agent);
465
+ if (project !== undefined)
466
+ this.agentProjects.set(agent.id, project);
467
+ const toolDeny = [];
468
+ for (const serverName of denySetFor(project, groups)) {
469
+ toolDeny.push(...toolNamesOf(serverName));
470
+ }
471
+ this.applyRestriction(agent, toolDeny);
472
+ }
473
+ }
474
+ applyRestriction(agent, deny) {
475
+ const previous = this.restrictions.get(agent.id);
476
+ if (previous !== undefined) {
477
+ this.restrictions.delete(agent.id);
478
+ try {
479
+ previous();
480
+ }
481
+ catch {
482
+ // agent 层已销毁
483
+ }
484
+ }
485
+ if (deny.length === 0)
486
+ return;
487
+ try {
488
+ const disposer = agent.ctx.tools.restrict({ deny });
489
+ this.restrictions.set(agent.id, disposer);
490
+ }
491
+ catch (error) {
492
+ // 未知名(装载未 settle)竞态:下一次 sweep 会补上。
493
+ this.ctx.logger.warn(`会话 ${agent.id} 的项目 MCP 过滤暂未应用:${error instanceof Error ? error.message : String(error)}`);
494
+ }
495
+ }
496
+ releaseAgent(agent) {
497
+ this.agentProjects.delete(agent.id);
498
+ const previous = this.restrictions.get(agent.id);
499
+ if (previous !== undefined) {
500
+ this.restrictions.delete(agent.id);
501
+ try {
502
+ previous();
503
+ }
504
+ catch {
505
+ // 已随 agent 销毁
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * 会话项目 key:会话 cwd 的项目根;子代理无 cwd 时回退 owner 的项目;
511
+ * 均无时回退 dsh 进程 cwd 所在项目。
512
+ */
513
+ async resolveProject(agent) {
514
+ try {
515
+ const cwd = agent?.session?.header?.cwd;
516
+ if (typeof cwd === "string" && cwd !== "") {
517
+ return projectKeyOf(await findProjectRoot(cwd));
518
+ }
519
+ }
520
+ catch {
521
+ // 回退 owner
522
+ }
523
+ try {
524
+ for (const candidate of this.liveAgents()) {
525
+ if (candidate === agent)
526
+ continue;
527
+ if (this.ctx.agents?.isOwnedBy?.(agent.id, candidate)) {
528
+ const own = this.agentProjects.get(candidate.id);
529
+ if (own !== undefined)
530
+ return own;
531
+ const resolved = await this.resolveProject(candidate);
532
+ if (resolved !== undefined)
533
+ return resolved;
534
+ }
535
+ }
536
+ }
537
+ catch {
538
+ // 视为无项目
539
+ }
540
+ try {
541
+ return projectKeyOf(await findProjectRoot(process.cwd()));
542
+ }
543
+ catch {
544
+ // 视为无项目
545
+ }
546
+ return undefined;
547
+ }
548
+ // ── 对外查询 ─────────────────────────────────────────────────────────
549
+ /** 立即执行一次全量对账(供外部在写入文件后调用,不等 watcher)。 */
550
+ reconcileNow() {
551
+ return this.enqueue(async () => {
552
+ await this.reconcileAll();
553
+ });
554
+ }
555
+ /** 等待某项目某行的装载状态满足 predicate(写入后 reconciliation 用)。 */
556
+ async waitForState(projectRoot, rawName, predicate, timeoutMs = 3000) {
557
+ const key = projectKeyOf(projectRoot);
558
+ const started = Date.now();
559
+ while (Date.now() - started < timeoutMs) {
560
+ const state = this.projects.get(key)?.servers.get(rawName);
561
+ if (predicate(state))
562
+ return true;
563
+ await delay(200);
564
+ }
565
+ return false;
566
+ }
567
+ /** 行级 view;未装载时 phase 按行状态推导。 */
568
+ async serverView(projectRoot, rawName) {
569
+ const key = projectKeyOf(projectRoot);
570
+ const entry = this.projects.get(key);
571
+ if (entry === undefined)
572
+ return undefined;
573
+ const state = entry.servers.get(rawName);
574
+ const path = projectMcpFile(projectRoot);
575
+ let row;
576
+ try {
577
+ const raw = await readPatchFile(path);
578
+ row = extractManagedRows(raw).find((candidate) => rowNameOf(candidate) === rawName);
579
+ }
580
+ catch {
581
+ row = state?.row;
582
+ }
583
+ if (row === undefined)
584
+ return undefined;
585
+ const view = patchRowToView(row, { kind: "workspace", path: projectRoot });
586
+ if (view === undefined)
587
+ return undefined;
588
+ const effectiveName = this.effective.get(key + "\u0000" + rawName);
589
+ return {
590
+ ...view,
591
+ ...(effectiveName === undefined ? {} : { effectiveServerName: effectiveName }),
592
+ fiberPhase: state === undefined ? (row.disabled === true ? null : "pending") : phaseToFiberPhase(state.phase),
593
+ toolCount: state !== undefined && state.phase === "active" ? mcpToolCount(this.ctx, state.effectiveName) : 0
594
+ };
595
+ }
596
+ /** 全量快照:每个已知项目一个文件分区。 */
597
+ async snapshot() {
598
+ await this.enqueue(async () => {
599
+ await this.reconcileAll();
600
+ });
601
+ const out = [];
602
+ for (const [key, entry] of this.projects) {
603
+ const path = projectMcpFile(entry.projectRoot);
604
+ const file = { project: entry.projectRoot, path, ok: true, error: null, servers: [] };
605
+ let raw;
606
+ try {
607
+ raw = await readPatchFile(path);
608
+ }
609
+ catch (error) {
610
+ if (entry.servers.size === 0)
611
+ continue; // 无配置文件且无装载:不展示
612
+ file.ok = false;
613
+ file.error = error instanceof Error ? error.message : String(error);
614
+ out.push(file);
615
+ continue;
616
+ }
617
+ for (const row of extractManagedRows(raw)) {
618
+ const rawName = rowNameOf(row);
619
+ if (rawName === undefined)
620
+ continue;
621
+ const view = patchRowToView(row, { kind: "workspace", path: entry.projectRoot });
622
+ if (view === undefined)
623
+ continue;
624
+ const state = entry.servers.get(rawName);
625
+ const effectiveName = this.effective.get(key + "\u0000" + rawName);
626
+ file.servers.push({
627
+ ...view,
628
+ ...(effectiveName === undefined ? {} : { effectiveServerName: effectiveName }),
629
+ fiberPhase: state === undefined ? (row.disabled === true ? null : "pending") : phaseToFiberPhase(state.phase),
630
+ toolCount: state !== undefined && state.phase === "active" ? mcpToolCount(this.ctx, state.effectiveName) : 0
631
+ });
632
+ }
633
+ out.push(file);
634
+ }
635
+ return out;
636
+ }
637
+ }
package/lib/status.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * dsh-project-mcp-manager —— MCP 行运行时状态读取(工具计数)。
3
+ */
4
+ /** 统计某 serverName 当前在全局工具层注册的工具数(前缀 mcp__<serverName>__)。 */
5
+ export function mcpToolCount(ctx, serverName) {
6
+ const tools = ctx?.tools;
7
+ if (tools === undefined || typeof tools.schemas !== "function")
8
+ return 0;
9
+ const prefix = `mcp__${serverName}__`;
10
+ const schemas = tools.schemas();
11
+ return Array.isArray(schemas) ? schemas.filter((schema) => typeof schema?.name === "string" && schema.name.startsWith(prefix)).length : 0;
12
+ }