dsh-cc-mcp 0.2.0 → 0.3.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/src/index.js CHANGED
@@ -11,11 +11,18 @@
11
11
  // timeout, and project .mcp.json changes hot-reload. Env values are expanded
12
12
  // at runtime from process.env — nothing is written to disk.
13
13
  //
14
+ // The management surface (see ./manage.js) rides on top of that registration
15
+ // path: every server keeps a status row (ready / error / disabled / skipped)
16
+ // with its tool list, the `/mcp` host command renders those rows as text, and
17
+ // the `/cc-mcp` Connection RPC channel serves the Web GUI panel — including
18
+ // manual reconnect and the persisted enable/disable decision (the only file
19
+ // this plugin writes).
20
+ //
14
21
  // Safety: apply() never throws synchronously; per-agent failures are logged,
15
22
  // never fatal to the host.
16
23
 
17
- import { readFile, mkdir, appendFile } from 'node:fs/promises'
18
24
  import { watchFile, unwatchFile } from 'node:fs'
25
+ import { createRequire } from 'node:module'
19
26
  import { join } from 'node:path'
20
27
  import Schema from '@deepseek-ai/schemastery'
21
28
  import { findProjectRoot, discoverProjectMcp, discoverPluginRoot } from 'dsh-cc-loader'
@@ -24,9 +31,21 @@ import { findProjectRoot, discoverProjectMcp, discoverPluginRoot } from 'dsh-cc-
24
31
  // manifest (discoverPluginRoot prefers manifest `name`; pluginNameOf is the
25
32
  // no-manifest fallback).
26
33
  export { pluginNameOf } from 'dsh-cc-loader'
27
- import {
34
+
35
+ // DSH's hot mount re-imports the ROW's module URL (…/src/index.js?v=N), but a
36
+ // plain `./manage.js` keeps the first load's module instance in Node's ESM
37
+ // cache — a reload would then pair new entry code with stale helpers. Hand the
38
+ // entry's own cache-buster to every relative import so the three modules always
39
+ // come from the same load. (Cold start: no query, single instance, unchanged.)
40
+ const MODULE_SUFFIX = new URL(import.meta.url).search
41
+
42
+ const {
28
43
  publicToolName, connectAndList, createDefinition, clearIdle,
29
- } from './register.js'
44
+ } = await import(`./register.js${MODULE_SUFFIX}`)
45
+ const {
46
+ RPC_CHANNEL, createDisabledStore, createRouteHandler, defaultStatePath, failuresOf, formatTextReport,
47
+ rpcFail, rpcOk, serverEntry, serverKey, snapshotOf, unknownSnapshot,
48
+ } = await import(`./manage.js${MODULE_SUFFIX}`)
30
49
 
31
50
  export const name = 'dsh-cc-mcp'
32
51
  export const inject = []
@@ -38,30 +57,112 @@ export const Config = Schema.object({
38
57
  toolCallTimeoutMs: Schema.number().default(60000),
39
58
  watchProject: Schema.boolean().default(true),
40
59
  projectRootMarkers: Schema.array(Schema.string()).default(['.git', '.dsh', '.claude']),
60
+ // Management surface: the `/mcp` command and the panel's route.
61
+ enableManager: Schema.boolean().default(true),
62
+ // List the host's `dsh-mcp-client` rows in the panel (and allow hiding them
63
+ // for this workspace). They are the host's to connect; this plugin never
64
+ // rewrites the profile config.
65
+ manageHostRows: Schema.boolean().default(true),
66
+ // Explicit state-file override. Empty = per workspace:
67
+ // `<projectRoot>/.dsh/cc-mcp-state.json`.
68
+ statePath: Schema.string().default(''),
41
69
  })
42
70
 
43
71
  const DEFAULT_CALL_TIMEOUT_MS = 60000
44
72
  const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000
45
73
  const WATCH_DEBOUNCE_MS = 300
74
+ /** How long the panel's `check` call waits for the session's self-check. */
75
+ const CHECK_WAIT_MS = 20000
76
+
77
+ const VERSION = (() => {
78
+ try {
79
+ return createRequire(import.meta.url)('../package.json').version ?? '0.0.0'
80
+ } catch {
81
+ return '0.0.0'
82
+ }
83
+ })()
46
84
 
47
85
  // Per-agent live controllers; module-level because agent/created wiring must
48
86
  // outlive one listener call. Cleanup on agent/disposed and plugin unload.
49
87
  const agentStates = new Map()
50
88
  const projectWatchers = new Map() // projectRoot -> { controllers: Set, timer }
51
89
 
90
+ // Enable/disable decisions are WORKSPACE-scoped: each project keeps its own
91
+ // `<projectRoot>/.dsh/cc-mcp-state.json`, so disabling github here does not
92
+ // touch another workspace and never edits the host's profile config. Sessions
93
+ // without a project root fall back to the machine-wide file.
94
+ const stores = new Map() // projectRoot -> { store, ready }
95
+ let globalStore = null
96
+ let globalStoreReady = Promise.resolve([])
97
+
98
+ // The resolved config of the live plugin instance, so late wiring (a session
99
+ // whose agent predates this plugin) can build a controller with the same
100
+ // options as the listener path.
101
+ let activeConfig = null
102
+
52
103
  function log(ctx, level, message) {
53
104
  try { ctx.logger?.[level]?.(`dsh-cc-mcp: ${message}`) } catch { /* logger absence is not fatal */ }
54
105
  }
55
106
 
107
+ /** Workspace state file: `<projectRoot>/.dsh/cc-mcp-state.json`. */
108
+ function workspaceStatePath(projectRoot) {
109
+ return join(projectRoot, '.dsh', 'cc-mcp-state.json')
110
+ }
111
+
112
+ /**
113
+ * Resolve (and lazily load) the store that owns one session's decisions.
114
+ * @param {object} state - the per-agent controller.
115
+ * @returns {Promise<object|undefined>}
116
+ */
117
+ async function storeFor(state) {
118
+ const projectRoot = state.projectRoot
119
+ if (projectRoot === undefined || projectRoot === null || projectRoot === '') {
120
+ await globalStoreReady
121
+ return globalStore ?? undefined
122
+ }
123
+ const pinned = typeof state.config.statePath === 'string' ? state.config.statePath.trim() : ''
124
+ const key = pinned === '' ? String(projectRoot) : `path:${pinned}`
125
+ let entry = stores.get(key)
126
+ if (entry === undefined) {
127
+ const store = createDisabledStore({
128
+ path: pinned === '' ? workspaceStatePath(String(projectRoot)) : pinned,
129
+ log: (level, message) => log(state.ctx, level, message),
130
+ })
131
+ entry = { store, ready: store.load() }
132
+ stores.set(key, entry)
133
+ }
134
+ await entry.ready
135
+ return entry.store
136
+ }
137
+
138
+ /** Whether a decision is recorded for this key — workspace first, then machine. */
139
+ function isDisabled(state, key) {
140
+ if (state.store?.has(key) === true) return true
141
+ return globalStore?.has(key) === true
142
+ }
143
+
56
144
  // ─── per-agent controller ────────────────────────────────────────────────────
57
145
 
58
146
  function createController(ctx, agent, agentCtx, baseCwd, config) {
147
+ let resolveCheck = null
148
+ const checkDone = new Promise((resolve) => { resolveCheck = resolve })
59
149
  return {
60
150
  ctx, agent, agentCtx, baseCwd, config,
61
151
  servers: new Map(),
152
+ sources: [],
62
153
  queue: Promise.resolve(),
63
154
  disposed: false,
64
155
  watcherRef: null,
156
+ checked: false,
157
+ reported: true,
158
+ checkDone,
159
+ resolveCheck,
160
+ // Resolved workspace store + this session's host-row hides (key -> disposer).
161
+ store: null,
162
+ hiddenHost: new Map(),
163
+ // Host rows this session adopted (we own their registration) — remembered so
164
+ // a hide/reveal round trip keeps the repaired connection.
165
+ adopted: new Set(),
65
166
  }
66
167
  }
67
168
 
@@ -72,12 +173,27 @@ function enqueue(state, fn) {
72
173
  return state.queue
73
174
  }
74
175
 
176
+ function settleCheck(state) {
177
+ state.checked = true
178
+ state.reported = false
179
+ const resolve = state.resolveCheck
180
+ state.resolveCheck = null
181
+ if (typeof resolve === 'function') resolve()
182
+ }
183
+
75
184
  function cleanupState(ctx, state, reason) {
76
185
  if (state.disposed) return
77
186
  state.disposed = true
78
187
  agentStates.delete(state.agent.id)
79
188
  for (const record of [...state.servers.values()]) teardownServer(state, record, reason).catch(() => {})
189
+ state.servers.clear()
190
+ // Lift this session's host-row hides so nothing outlives the controller.
191
+ for (const dispose of state.hiddenHost.values()) {
192
+ try { dispose() } catch { /* the scope is going away anyway */ }
193
+ }
194
+ state.hiddenHost.clear()
80
195
  detachWatcher(state)
196
+ settleCheck(state)
81
197
  log(ctx, 'info', `agent ${state.agent.id} (${state.baseCwd}): cleanup (${reason})`)
82
198
  }
83
199
 
@@ -110,13 +226,108 @@ export function toRuntimeEntries(servers, opts = {}) {
110
226
  return out
111
227
  }
112
228
 
229
+ // ─── host rows (@deepseek-ai/dsh-mcp-client) ────────────────────────────────
230
+ //
231
+ // The host profile configures MCP servers as one `dsh-mcp-client` row each.
232
+ // They are NOT this plugin's to register — the host layer already owns their
233
+ // connections and their tools appear globally as `mcp__<server>__*`. The panel
234
+ // still lists them, and can act on them per workspace:
235
+ // Disable → hide that row's tools for THIS workspace's sessions only
236
+ // (`tools.restrict({ deny })`, lifted again on Enable);
237
+ // Connect → when the row exposes no tools, adopt it: connect with the row's
238
+ // own config and register the tools in the agent scope.
239
+
240
+ /** The host plugin each MCP server row is an instance of. */
241
+ const HOST_MCP_PLUGIN = '@deepseek-ai/dsh-mcp-client'
242
+
243
+ /** Host rows, read from the live Loader tree; empty when the service is absent. */
244
+ function hostRows(ctx) {
245
+ let loader = null
246
+ try {
247
+ loader = typeof ctx.get === 'function' ? ctx.get('loader', false) : undefined
248
+ } catch {
249
+ loader = undefined
250
+ }
251
+ if (!loader || typeof loader.entries !== 'function') return []
252
+ const rows = []
253
+ let entries
254
+ try {
255
+ entries = [...loader.entries()]
256
+ } catch {
257
+ return []
258
+ }
259
+ for (const entry of entries) {
260
+ const options = entry?.options
261
+ if (options === undefined || options === null) continue
262
+ if (options.name !== HOST_MCP_PLUGIN) continue
263
+ const config = options.config
264
+ if (config === null || typeof config !== 'object') continue
265
+ const serverName = typeof config.serverName === 'string' ? config.serverName : ''
266
+ if (serverName === '') continue
267
+ rows.push({
268
+ rowId: typeof options.id === 'string' ? options.id : '',
269
+ serverName,
270
+ transport: typeof config.transport === 'string' ? config.transport : undefined,
271
+ command: typeof config.command === 'string' ? config.command : undefined,
272
+ args: Array.isArray(config.args) ? config.args : undefined,
273
+ env: config.env !== null && typeof config.env === 'object' ? config.env : undefined,
274
+ cwd: typeof config.cwd === 'string' ? config.cwd : undefined,
275
+ url: typeof config.url === 'string' ? config.url : undefined,
276
+ headers: config.headers !== null && typeof config.headers === 'object' ? config.headers : undefined,
277
+ toolCallTimeoutMs: typeof config.toolCallTimeoutMs === 'number' ? config.toolCallTimeoutMs : undefined,
278
+ idleTimeoutMs: typeof config.idleTimeoutMs === 'number' ? config.idleTimeoutMs : undefined,
279
+ rowDisabled: options.disabled === true,
280
+ })
281
+ }
282
+ return rows
283
+ }
284
+
285
+ /** Tools the host layer currently exposes for one server, from the agent view. */
286
+ function hostToolsOf(state, serverName) {
287
+ const prefix = `mcp__${serverName}__`
288
+ try {
289
+ const schemas = state.agentCtx.tools.schemas(state.agent)
290
+ if (!Array.isArray(schemas)) return []
291
+ return schemas
292
+ .filter((schema) => typeof schema?.name === 'string' && schema.name.startsWith(prefix))
293
+ .map((schema) => ({
294
+ name: schema.name,
295
+ rawName: schema.name.slice(prefix.length),
296
+ description: typeof schema.description === 'string' ? schema.description : '',
297
+ }))
298
+ } catch {
299
+ return []
300
+ }
301
+ }
302
+
303
+ /** One host row as a runtime entry this plugin can describe (and adopt). */
304
+ function hostRuntimeEntry(row, state) {
305
+ return {
306
+ hostRow: true,
307
+ serverName: row.serverName,
308
+ rowId: row.rowId,
309
+ transport: row.transport,
310
+ command: row.command,
311
+ args: row.args ?? [],
312
+ env: row.env,
313
+ cwd: row.cwd,
314
+ url: row.url,
315
+ headers: row.headers,
316
+ toolCallTimeoutMs: row.toolCallTimeoutMs ?? state.config.toolCallTimeoutMs,
317
+ idleTimeoutMs: row.idleTimeoutMs ?? state.config.idleTimeoutMs,
318
+ source: `host MCP client row${row.rowId === '' ? '' : ` "${row.rowId}"`} (profile config)`,
319
+ }
320
+ }
321
+
113
322
  async function collectEntries(state) {
114
323
  const cfg = state.config
115
324
  const entries = []
116
325
  const sources = []
117
326
  if (cfg.enableProject && state.projectRoot !== undefined) {
118
327
  const found = await discoverProjectMcp(state.projectRoot, { warn: (m) => log(state.ctx, 'warn', `agent ${state.agent.id}: ${m}`) })
119
- entries.push(...toRuntimeEntries(found.servers, { idleTimeoutMs: cfg.idleTimeoutMs, toolCallTimeoutMs: cfg.toolCallTimeoutMs }))
328
+ for (const entry of toRuntimeEntries(found.servers, { idleTimeoutMs: cfg.idleTimeoutMs, toolCallTimeoutMs: cfg.toolCallTimeoutMs })) {
329
+ entries.push({ ...entry, source: found.sources?.[0] ?? join(state.projectRoot, '.mcp.json') })
330
+ }
120
331
  sources.push(...found.sources)
121
332
  }
122
333
  for (const root of cfg.pluginRoots) {
@@ -124,13 +335,19 @@ async function collectEntries(state) {
124
335
  // wins over the directory name for the tool namespace, and manifest
125
336
  // `mcpServers` path/inline configs are included alongside .mcp.json.
126
337
  const plugin = await discoverPluginRoot(root, { warn: (m) => log(state.ctx, 'warn', `agent ${state.agent.id}: ${m}`) })
127
- entries.push(...toRuntimeEntries(plugin.components.mcp.servers, {
338
+ for (const entry of toRuntimeEntries(plugin.components.mcp.servers, {
128
339
  pluginRoot: root,
129
340
  idleTimeoutMs: cfg.idleTimeoutMs,
130
341
  toolCallTimeoutMs: cfg.toolCallTimeoutMs,
131
- }))
342
+ })) {
343
+ const own = plugin.components.mcp.sources?.find((source) => source.includes(entry.serverName))
344
+ entries.push({ ...entry, source: own ?? plugin.components.mcp.sources?.[0] ?? root })
345
+ }
132
346
  sources.push(...plugin.components.mcp.sources)
133
347
  }
348
+ if (cfg.manageHostRows !== false) {
349
+ for (const row of hostRows(state.ctx)) entries.push(hostRuntimeEntry(row, state))
350
+ }
134
351
  return { entries, sources }
135
352
  }
136
353
 
@@ -149,7 +366,7 @@ async function syncSchema(state, entry) {
149
366
  const { client, tools } = await connectAndList(entry, state.baseCwd)
150
367
  try {
151
368
  const definitions = new Map()
152
- const prefix = entry.pluginName ? `mcp__plugin_${entry.pluginName}_${entry.serverName}__` : `mcp__${entry.serverName}__`
369
+ const listed = []
153
370
  for (const tool of tools) {
154
371
  const publicName = publicToolName(
155
372
  { serverName: entry.serverName, pluginName: entry.pluginName },
@@ -159,6 +376,11 @@ async function syncSchema(state, entry) {
159
376
  throw new Error(`server listed tool "${tool.name}" more than once — invalid tool list`)
160
377
  }
161
378
  definitions.set(publicName, createDefinition(state, entry, tool.name, publicName, tool))
379
+ listed.push({
380
+ name: publicName,
381
+ rawName: tool.name,
382
+ description: typeof tool.description === 'string' ? tool.description : '',
383
+ })
162
384
  }
163
385
  const disposers = []
164
386
  for (const [publicName, definition] of definitions) {
@@ -169,8 +391,7 @@ async function syncSchema(state, entry) {
169
391
  }
170
392
  }
171
393
  return {
172
- toolCount: disposers.length,
173
- prefix,
394
+ tools: listed,
174
395
  unregister: () => {
175
396
  for (const dispose of disposers) {
176
397
  try { dispose() } catch { /* best effort */ }
@@ -182,51 +403,298 @@ async function syncSchema(state, entry) {
182
403
  }
183
404
  }
184
405
 
406
+ /**
407
+ * Create (or replace) one server's status row and register its tools when it
408
+ * is reachable. Every outcome — ready, error, disabled, skipped — leaves a row
409
+ * behind, because the panel and the self-check report both read that row.
410
+ * @returns {Promise<object>} the live record
411
+ */
185
412
  async function setupServer(state, entry) {
186
- const serverName = entry.serverName
187
- const prefix = entry.pluginName ? `mcp__plugin_${entry.pluginName}_${serverName}__` : `mcp__${serverName}__`
413
+ const key = serverKey(entry)
414
+ const previous = state.servers.get(key)
415
+ const prefix = entry.pluginName ? `mcp__plugin_${entry.pluginName}_${entry.serverName}__` : `mcp__${entry.serverName}__`
416
+ const record = {
417
+ key,
418
+ entry,
419
+ toolPrefix: prefix,
420
+ source: entry.source ?? null,
421
+ unregister: null,
422
+ conn: null,
423
+ connecting: null,
424
+ tools: previous?.tools ?? [],
425
+ status: 'checking',
426
+ error: null,
427
+ updatedAt: Date.now(),
428
+ }
429
+ state.servers.set(key, record)
430
+
431
+ if (isDisabled(state, key)) {
432
+ record.status = 'disabled'
433
+ log(state.ctx, 'info', `agent ${state.agent.id}: server ${entry.serverName} disabled in this workspace — not registered`)
434
+ return record
435
+ }
436
+
437
+ if (entry.hostRow === true) {
438
+ return setupHostRow(state, entry, record)
439
+ }
440
+
188
441
  const upper = hasUpperServerTools(state, prefix)
189
442
  if (!entry.override && upper) {
190
- log(state.ctx, 'info', `agent ${state.agent.id}: server ${serverName} already provided by preset/host MCP — skipped (set "override": true to force)`)
191
- return
443
+ // Another layer already publishes this namespace. Report what is visible so
444
+ // the panel is honest about it, and leave the registration alone.
445
+ record.status = 'skipped'
446
+ record.tools = hostToolsOf(state, entry.serverName)
447
+ record.error = null
448
+ record.updatedAt = Date.now()
449
+ log(state.ctx, 'info', `agent ${state.agent.id}: server ${entry.serverName} already provided by preset/host MCP — skipped (set "override": true to force)`)
450
+ return record
192
451
  }
452
+
193
453
  try {
194
- const { toolCount, unregister } = await syncSchema(state, entry)
454
+ const { tools, unregister } = await syncSchema(state, entry)
195
455
  if (state.disposed) {
196
456
  unregister()
197
- return
457
+ return record
198
458
  }
199
- state.servers.set(serverName, { entry, unregister, conn: null, connecting: null })
459
+ record.unregister = unregister
460
+ record.tools = tools
461
+ record.status = 'ready'
462
+ record.managed = true
200
463
  const shadowed = upper ? ' (agent layer shadows upper-layer registration(s))' : ''
201
- log(state.ctx, 'info', `agent ${state.agent.id}: registered ${toolCount} tool(s) from server ${serverName}${shadowed}`)
464
+ log(state.ctx, 'info', `agent ${state.agent.id}: registered ${tools.length} tool(s) from server ${entry.serverName}${shadowed}`)
465
+ } catch (error) {
466
+ record.status = 'error'
467
+ record.error = error instanceof Error ? error.message : String(error)
468
+ log(state.ctx, 'error', `agent ${state.agent.id}: server ${entry.serverName} not loaded: ${record.error}`)
469
+ }
470
+ record.updatedAt = Date.now()
471
+ return record
472
+ }
473
+
474
+ /**
475
+ * A host `dsh-mcp-client` row: describe it in this workspace's panel, and —
476
+ * only when the row exposes nothing and the caller asked to adopt — connect it
477
+ * with the row's own config so this workspace keeps working tools.
478
+ */
479
+ async function setupHostRow(state, entry, record) {
480
+ const name = entry.serverName
481
+ // An adopted row belongs to this session: every rebuild re-registers it
482
+ // (a teardown cleared the previous registration). A plain host row is only
483
+ // described — this plugin never reconnects what the host layer owns.
484
+ const wantsAdopt = entry.adopt === true || state.adopted.has(name)
485
+ const tools = hostToolsOf(state, name)
486
+ if (!wantsAdopt) {
487
+ if (tools.length > 0) {
488
+ record.tools = tools
489
+ record.status = 'ready'
490
+ record.managed = false
491
+ record.error = null
492
+ } else {
493
+ record.status = 'error'
494
+ record.managed = false
495
+ record.adoptable = true
496
+ record.error = entry.rowDisabled === true
497
+ ? 'the host row is disabled in the profile config'
498
+ : 'the host MCP client row exposed no tools (its own connection failed or is still starting) — Connect adopts it for this workspace'
499
+ }
500
+ record.updatedAt = Date.now()
501
+ return record
502
+ }
503
+ try {
504
+ const { tools: listed, unregister } = await syncSchema(state, { ...entry, override: true })
505
+ if (state.disposed) {
506
+ unregister()
507
+ return record
508
+ }
509
+ record.unregister = unregister
510
+ record.tools = listed
511
+ record.status = 'ready'
512
+ record.managed = true
513
+ record.adoptable = false
514
+ record.error = null
515
+ state.adopted.add(name)
516
+ log(state.ctx, 'info', `agent ${state.agent.id}: adopted host row ${name} (${listed.length} tool(s))`)
202
517
  } catch (error) {
203
- log(state.ctx, 'error', `agent ${state.agent.id}: server ${serverName} not loaded: ${String(error)}`)
518
+ record.status = 'error'
519
+ record.managed = false
520
+ record.adoptable = true
521
+ record.error = error instanceof Error ? error.message : String(error)
522
+ log(state.ctx, 'error', `agent ${state.agent.id}: host row ${name} not adopted: ${record.error}`)
204
523
  }
524
+ record.updatedAt = Date.now()
525
+ return record
205
526
  }
206
527
 
207
528
  async function teardownServer(state, record, reason) {
208
- state.servers.delete(record.entry.serverName)
209
- try { record.unregister() } catch { /* best effort */ }
529
+ const dispose = record.unregister
530
+ record.unregister = null
531
+ if (typeof dispose === 'function') {
532
+ try { dispose() } catch { /* best effort */ }
533
+ }
534
+ record.managed = false
210
535
  const conn = record.conn
211
- if (conn !== null) {
212
- record.conn = null
536
+ record.conn = null
537
+ record.connecting = null
538
+ if (conn !== null && conn !== undefined) {
213
539
  clearIdle(conn)
214
540
  try { await conn.client.close() } catch { /* best effort */ }
215
541
  }
216
542
  log(state.ctx, 'info', `agent ${state.agent.id}: server ${record.entry.serverName}: ${reason}`)
217
543
  }
218
544
 
545
+ // ─── management operations (the panel's verbs) ──────────────────────────────
546
+
547
+ /**
548
+ * Reconnect one server on this session and report its post-attempt row.
549
+ * For a host row that exposes nothing, this is the adoption path: this plugin
550
+ * connects with the row's own config and registers the tools in the agent scope.
551
+ */
552
+ async function connectServer(state, key) {
553
+ const record = state.servers.get(key)
554
+ if (record === undefined) {
555
+ return { ok: false, code: 'cc-mcp/unknown-server', message: `unknown server ${JSON.stringify(key)}` }
556
+ }
557
+ if (isDisabled(state, key)) {
558
+ return { ok: false, code: 'cc-mcp/disabled', message: `server ${record.entry.serverName} is disabled in this workspace — enable it first` }
559
+ }
560
+ await enqueue(state, async () => {
561
+ await teardownServer(state, record, record.entry.hostRow === true ? 'adopting host row' : 'manual reconnect')
562
+ await setupServer(state, record.entry.hostRow === true ? { ...record.entry, adopt: true } : record.entry)
563
+ })
564
+ return { ok: true, entry: serverEntry(state.servers.get(key) ?? record) }
565
+ }
566
+
567
+ /**
568
+ * Enable/disable one server in this WORKSPACE and persist the decision to the
569
+ * workspace's own state file. A managed server simply stops being registered;
570
+ * a host row is hidden with a per-session tool restriction (the host row's own
571
+ * connection is never touched, so other workspaces keep it).
572
+ */
573
+ async function setServerDisabled(state, key, disabled) {
574
+ const record = state.servers.get(key)
575
+ if (record === undefined) {
576
+ return { ok: false, code: 'cc-mcp/unknown-server', message: `unknown server ${JSON.stringify(key)}` }
577
+ }
578
+ const store = state.store ?? globalStore
579
+ if (store === null || store === undefined) {
580
+ return { ok: false, code: 'cc-mcp/no-store', message: 'the workspace state store is unavailable in this deployment' }
581
+ }
582
+ await store.set(key, disabled === true)
583
+ for (const target of [...agentStates.values()]) {
584
+ const targetRecord = target.servers.get(key)
585
+ if (targetRecord === undefined) continue
586
+ // A workspace store only governs sessions in that same workspace; a session
587
+ // rooted elsewhere keeps its own decision.
588
+ if (target.store !== store) continue
589
+ await enqueue(target, async () => {
590
+ if (targetRecord.entry.hostRow === true) {
591
+ await applyHostVisibility(target, targetRecord, disabled === true)
592
+ return
593
+ }
594
+ await teardownServer(target, targetRecord, disabled === true ? 'disabled in this workspace' : 'enabled in this workspace')
595
+ await setupServer(target, targetRecord.entry)
596
+ })
597
+ }
598
+ return { ok: true, entry: serverEntry(state.servers.get(key) ?? record) }
599
+ }
600
+
601
+ /**
602
+ * Hide or reveal one host row's tools for this session.
603
+ * `tools.restrict` is the platform's per-scope mask and returns the disposer
604
+ * that lifts it, so Enable is exactly Hide undone.
605
+ */
606
+ async function applyHostVisibility(state, record, hidden) {
607
+ const name = record.entry.serverName
608
+ if (hidden) {
609
+ if (record.managed === true) {
610
+ // An adopted row is ours: dropping the registration is the honest hide.
611
+ await teardownServer(state, record, 'disabled in this workspace')
612
+ }
613
+ const names = hostToolsOf(state, name).map((tool) => tool.name)
614
+ if (names.length > 0) {
615
+ let dispose = null
616
+ try {
617
+ dispose = state.agentCtx.tools.restrict({ deny: names })
618
+ } catch {
619
+ // Unknown names are rejected per call, so fall back to one at a time.
620
+ const disposers = []
621
+ for (const toolName of names) {
622
+ try { disposers.push(state.agentCtx.tools.restrict({ deny: [toolName] })) } catch { /* unknown tool: not visible anyway */ }
623
+ }
624
+ if (disposers.length > 0) {
625
+ dispose = () => {
626
+ for (const lifted of disposers.reverse()) {
627
+ try { lifted() } catch { /* already gone */ }
628
+ }
629
+ }
630
+ }
631
+ }
632
+ if (dispose === null) {
633
+ log(state.ctx, 'warn', `agent ${state.agent.id}: could not restrict ${name} tools — they stay visible`)
634
+ } else {
635
+ const previous = state.hiddenHost.get(name)
636
+ if (typeof previous === 'function') {
637
+ try { previous() } catch { /* replaced */ }
638
+ }
639
+ state.hiddenHost.set(name, dispose)
640
+ }
641
+ }
642
+ record.status = 'disabled'
643
+ record.error = null
644
+ record.updatedAt = Date.now()
645
+ log(state.ctx, 'info', `agent ${state.agent.id}: host row ${name} hidden in this workspace`)
646
+ return record
647
+ }
648
+
649
+ const dispose = state.hiddenHost.get(name)
650
+ if (typeof dispose === 'function') {
651
+ state.hiddenHost.delete(name)
652
+ try { dispose() } catch (error) { log(state.ctx, 'warn', `agent ${state.agent.id}: lifting the ${name} restriction failed: ${String(error)}`) }
653
+ }
654
+ await setupServer(state, record.entry)
655
+ return state.servers.get(record.key) ?? record
656
+ }
657
+
658
+ /**
659
+ * Failures from the session's self-check, reported at most once per check
660
+ * pass so a reconnecting panel cannot re-raise yesterday's toasts.
661
+ */
662
+ async function pendingFailures(state) {
663
+ await Promise.race([
664
+ state.checkDone,
665
+ new Promise((resolve) => {
666
+ const timer = setTimeout(resolve, CHECK_WAIT_MS)
667
+ timer.unref?.()
668
+ }),
669
+ ])
670
+ if (state.reported === true) return []
671
+ const failures = failuresOf(state)
672
+ if (failures.length > 0) state.reported = true
673
+ return failures
674
+ }
675
+
219
676
  // ─── config application (initial load + hot reload share one path) ──────────
220
677
 
221
678
  async function applyConfig(state) {
222
679
  if (state.disposed) return
223
- const { entries } = await collectEntries(state)
680
+ // The workspace store must be loaded before anything registers, so a server
681
+ // disabled in this workspace is never briefly visible.
682
+ if (state.store === null) state.store = await storeFor(state)
683
+ const { entries, sources } = await collectEntries(state)
684
+ state.sources = sources
224
685
  for (const record of [...state.servers.values()]) {
225
686
  await teardownServer(state, record, 'config change — rebuilding')
226
687
  }
227
688
  for (const entry of entries) {
689
+ // setupServer itself answers `disabled` for any key recorded in this
690
+ // workspace's store, host rows included.
228
691
  await setupServer(state, entry)
229
692
  }
693
+ // A server removed from the config must not linger as a stale panel row.
694
+ const live = new Set(entries.map((entry) => serverKey(entry)))
695
+ for (const key of [...state.servers.keys()]) {
696
+ if (!live.has(key)) state.servers.delete(key)
697
+ }
230
698
  }
231
699
 
232
700
  async function reloadFromDisk(state) {
@@ -253,7 +721,12 @@ function attachWatcher(state) {
253
721
  w.timer = setTimeout(() => {
254
722
  w.timer = null
255
723
  for (const controller of [...w.controllers]) {
256
- if (!controller.disposed) enqueue(controller, () => reloadFromDisk(controller))
724
+ if (!controller.disposed) {
725
+ enqueue(controller, async () => {
726
+ await reloadFromDisk(controller)
727
+ settleCheck(controller)
728
+ })
729
+ }
257
730
  }
258
731
  }, WATCH_DEBOUNCE_MS)
259
732
  }
@@ -269,7 +742,9 @@ function attachWatcher(state) {
269
742
 
270
743
  function detachWatcher(state) {
271
744
  const w = state.watcherRef
272
- if (w === undefined) return
745
+ // `null` is the "never attached" value createController writes; only an
746
+ // attached watcher has controllers to detach from.
747
+ if (w === undefined || w === null) return
273
748
  state.watcherRef = undefined
274
749
  w.controllers.delete(state)
275
750
  if (w.controllers.size === 0 && state.projectRoot !== undefined) {
@@ -279,6 +754,191 @@ function detachWatcher(state) {
279
754
  }
280
755
  }
281
756
 
757
+ // ─── management wiring (command + plugin-owned HTTP route) ──────────────────
758
+ //
759
+ // The panel's data channel is a route this plugin registers on the host's
760
+ // `webServer` service — the pattern the shipped marketplace plugin uses
761
+ // (`ctx.inject(['webServer'], …)` → `hostCtx.webServer.register(…)`). The
762
+ // generic `connection` RPC service is NOT visible to a profile-level plugin in
763
+ // this host (verified against a running `dsh web`), so it cannot carry this
764
+ // plugin's traffic.
765
+
766
+ /** Registration facts the `diag` endpoint reports, for support and tests. */
767
+ const manager = {
768
+ enabled: false,
769
+ webServerAvailable: false,
770
+ routeRegistered: false,
771
+ routeError: null,
772
+ agentsServiceAvailable: false,
773
+ }
774
+
775
+ /**
776
+ * Resolve a live Agent for a session id through the host's `ctx.agents`
777
+ * registry. Used to wire a session whose agent already existed when this
778
+ * plugin activated (a host restart restores sessions before user-layer
779
+ * plugins mount, so their `agent/created` was never observable).
780
+ */
781
+ function resolveLiveAgent(ctx, sessionId) {
782
+ if (sessionId === '') return undefined
783
+ let agents = null
784
+ try {
785
+ agents = typeof ctx.get === 'function' ? ctx.get('agents', false) : undefined
786
+ } catch {
787
+ agents = undefined
788
+ }
789
+ if (!agents || typeof agents !== 'object') return undefined
790
+ manager.agentsServiceAvailable = true
791
+ try {
792
+ if (typeof agents.get === 'function') return agents.get(sessionId)
793
+ // `store` is the registry's private Map; reading it keeps this working on a
794
+ // host that exposes no accessor. Both paths are best-effort by design.
795
+ const store = agents.store
796
+ if (store instanceof Map) {
797
+ const entry = store.get(sessionId)
798
+ if (entry === undefined) return undefined
799
+ return entry.agent ?? entry
800
+ }
801
+ } catch {
802
+ return undefined
803
+ }
804
+ return undefined
805
+ }
806
+
807
+ /**
808
+ * The controller for one session, wiring it on demand when it was missed.
809
+ * A late wiring waits for the queued self-check, so the first panel open shows
810
+ * settled rows instead of a screen full of spinners.
811
+ * @returns {Promise<object|undefined>}
812
+ */
813
+ async function ensureState(ctx, sessionId, agent) {
814
+ const existing = agentStates.get(sessionId)
815
+ if (existing !== undefined) return existing
816
+ if (!agent || agentCwd(agent) === undefined) return undefined
817
+ await wireAgent(ctx, agent, activeConfig ?? {})
818
+ const wired = agentStates.get(sessionId)
819
+ if (wired !== undefined) {
820
+ await Promise.race([
821
+ wired.checkDone,
822
+ new Promise((resolve) => {
823
+ const timer = setTimeout(resolve, CHECK_WAIT_MS)
824
+ timer.unref?.()
825
+ }),
826
+ ])
827
+ }
828
+ return wired
829
+ }
830
+
831
+ function registerCommand(ctx, config) {
832
+ ctx.inject(['commands'], (scope) => {
833
+ scope.effect(() => scope.commands.register({
834
+ name: 'mcp',
835
+ description: 'List the session\'s MCP servers and their connection status',
836
+ handler: async (invocation) => {
837
+ try {
838
+ const agent = invocation?.agent
839
+ const state = await ensureState(ctx, String(agent?.id ?? ''), agent)
840
+ if (state === undefined) {
841
+ return { kind: 'success', text: 'MCP servers: this session has no project MCP context yet.' }
842
+ }
843
+ return { kind: 'success', text: formatTextReport(snapshotOf(state)) }
844
+ } catch (error) {
845
+ return { kind: 'error', text: `mcp: ${error instanceof Error ? error.message : String(error)}` }
846
+ }
847
+ },
848
+ }), 'cc-mcp: /mcp command')
849
+ })
850
+ }
851
+
852
+ /** Endpoints the panel may call; anything else is refused before any work. */
853
+ const RPC_ENDPOINTS = new Set(['ping', 'diag', 'state', 'check', 'connect', 'disable'])
854
+
855
+ async function handleRpc(ctx, endpoint, payload) {
856
+ const request = payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? payload : {}
857
+ if (!RPC_ENDPOINTS.has(endpoint)) {
858
+ return rpcFail('cc-mcp/unknown-endpoint', `unknown endpoint ${JSON.stringify(String(endpoint))}`)
859
+ }
860
+ if (endpoint === 'ping') {
861
+ return rpcOk({
862
+ plugin: name,
863
+ version: VERSION,
864
+ sessions: agentStates.size,
865
+ manager: manager.enabled,
866
+ route: manager.routeRegistered,
867
+ })
868
+ }
869
+ if (endpoint === 'diag') {
870
+ const visible = {}
871
+ for (const state of agentStates.values()) {
872
+ try {
873
+ const schemas = state.agentCtx.tools.schemas(state.agent)
874
+ visible[String(state.agent.id)] = Array.isArray(schemas)
875
+ ? schemas.map((schema) => schema?.name).filter((name) => typeof name === 'string' && name.startsWith('mcp__'))
876
+ : []
877
+ } catch {
878
+ visible[String(state.agent.id)] = null
879
+ }
880
+ }
881
+ return rpcOk({
882
+ version: VERSION,
883
+ manager: { ...manager },
884
+ wiredSessions: [...agentStates.keys()].map(String),
885
+ projectRoots: [...agentStates.values()].map((entry) => entry.projectRoot ?? null),
886
+ visibleMcpTools: visible,
887
+ })
888
+ }
889
+ const sessionId = request.sessionId === undefined || request.sessionId === null ? '' : String(request.sessionId)
890
+ const state = await ensureState(ctx, sessionId, resolveLiveAgent(ctx, sessionId))
891
+ if (state === undefined) {
892
+ if (endpoint === 'state') return rpcOk(unknownSnapshot(request.sessionId))
893
+ return rpcFail('cc-mcp/unknown-session', `no live agent is observable for session ${sessionId === '' ? '(none)' : sessionId}`)
894
+ }
895
+ switch (endpoint) {
896
+ case 'state':
897
+ return rpcOk(snapshotOf(state))
898
+ case 'check': {
899
+ // Await the self-check BEFORE reading the flag: object-literal properties
900
+ // evaluate in order, so inlining the await would report the pre-wait state.
901
+ const failures = await pendingFailures(state)
902
+ return rpcOk({ checked: state.checked === true, failures })
903
+ }
904
+ case 'connect': {
905
+ const result = await connectServer(state, String(request.key ?? ''))
906
+ return result.ok ? rpcOk({ entry: result.entry }) : rpcFail(result.code, result.message)
907
+ }
908
+ case 'disable': {
909
+ const result = await setServerDisabled(state, String(request.key ?? ''), request.disabled === true)
910
+ return result.ok ? rpcOk({ entry: result.entry }) : rpcFail(result.code, result.message)
911
+ }
912
+ default:
913
+ // Unreachable: the endpoint allowlist above rejects anything else first.
914
+ return rpcFail('cc-mcp/unknown-endpoint', `unknown endpoint ${JSON.stringify(String(endpoint))}`)
915
+ }
916
+ }
917
+
918
+ function registerManager(ctx, config) {
919
+ manager.enabled = true
920
+ registerCommand(ctx, config)
921
+ ctx.inject(['webServer'], (scope) => {
922
+ manager.webServerAvailable = true
923
+ try {
924
+ scope.effect(() => scope.webServer.register({
925
+ kind: 'prefix',
926
+ path: RPC_CHANNEL,
927
+ handler: createRouteHandler({
928
+ dispatch: (endpoint, payload) => handleRpc(ctx, endpoint, payload),
929
+ log: (level, message) => log(ctx, level, message),
930
+ }),
931
+ }), 'cc-mcp: management routes')
932
+ manager.routeRegistered = true
933
+ manager.routeError = null
934
+ } catch (error) {
935
+ manager.routeRegistered = false
936
+ manager.routeError = error instanceof Error ? error.message : String(error)
937
+ log(ctx, 'error', `could not register ${RPC_CHANNEL}: ${manager.routeError}`)
938
+ }
939
+ })
940
+ }
941
+
282
942
  // ─── agent wiring ────────────────────────────────────────────────────────────
283
943
 
284
944
  function agentCwd(agent) {
@@ -303,14 +963,26 @@ async function wireAgent(ctx, agent, config) {
303
963
  state.projectRoot = projectRoot
304
964
  agentStates.set(agent.id, state)
305
965
  attachWatcher(state)
306
- enqueue(state, () => reloadFromDisk(state))
966
+ // The self-check runs beside the conversation, never in front of it: the
967
+ // queue serializes it with config reloads, and `checkDone` is what the
968
+ // panel's `check` endpoint waits on.
969
+ enqueue(state, async () => {
970
+ await reloadFromDisk(state)
971
+ settleCheck(state)
972
+ })
307
973
  }
308
974
 
309
975
  export function apply(ctx, config) {
310
976
  // apply() must never throw synchronously: a throw here kills the host.
977
+ activeConfig = config
978
+ // Idempotent wiring: `agent/created` covers sessions started after this
979
+ // plugin activates; `agent/session-start` is the startup-driving boundary and
980
+ // catches a session the host resumed in the same boot. A session whose agent
981
+ // predates both is wired on demand by ensureState().
311
982
  const wire = async (payload) => {
312
983
  const agent = payload && payload.agent
313
984
  if (!agent) return
985
+ if (agentStates.has(agent.id)) return
314
986
  try {
315
987
  await wireAgent(ctx, agent, config)
316
988
  } catch (error) {
@@ -318,18 +990,39 @@ export function apply(ctx, config) {
318
990
  }
319
991
  }
320
992
  try {
993
+ const pinned = typeof config.statePath === 'string' ? config.statePath.trim() : ''
994
+ globalStore = createDisabledStore({
995
+ path: pinned === '' ? defaultStatePath() : pinned,
996
+ log: (level, message) => log(ctx, level, message),
997
+ })
998
+ globalStoreReady = globalStore.load().catch((error) => {
999
+ log(ctx, 'error', `state load failed: ${String(error)}`)
1000
+ return []
1001
+ })
1002
+ stores.clear()
321
1003
  ctx.on('agent/created', (payload) => { void wire(payload) })
1004
+ ctx.on('agent/session-start', (payload) => { void wire(payload) })
322
1005
  ctx.on('agent/disposed', (payload) => {
323
1006
  const agent = payload && payload.agent
324
1007
  if (!agent) return
325
1008
  const state = agentStates.get(agent.id)
326
1009
  if (state !== undefined) cleanupState(ctx, state, 'agent disposed')
327
1010
  })
1011
+ if (config.enableManager) registerManager(ctx, config)
328
1012
  ctx.effect(() => () => {
329
1013
  for (const state of [...agentStates.values()]) cleanupState(ctx, state, 'plugin reloaded')
1014
+ globalStore = null
1015
+ globalStoreReady = Promise.resolve([])
1016
+ stores.clear()
1017
+ activeConfig = null
1018
+ manager.enabled = false
1019
+ manager.routeRegistered = false
1020
+ manager.webServerAvailable = false
330
1021
  })
331
- log(ctx, 'info', `plugin active — project .mcp.json ${config.enableProject ? 'enabled' : 'disabled'}, ${config.pluginRoots.length} plugin root(s)`)
1022
+ log(ctx, 'info', `plugin active — project .mcp.json ${config.enableProject ? 'enabled' : 'disabled'}, ${config.pluginRoots.length} plugin root(s), host rows ${config.manageHostRows === false ? 'ignored' : 'listed'}${config.enableManager ? ', management surface on' : ''}`)
332
1023
  } catch (error) {
333
1024
  log(ctx, 'error', `apply failed: ${String(error)}`)
334
1025
  }
335
1026
  }
1027
+
1028
+ export { RPC_CHANNEL }