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/README.md +77 -0
- package/client/index.js +1080 -0
- package/package.json +9 -4
- package/src/index.js +720 -27
- package/src/manage.js +362 -0
- package/src/register.js +10 -2
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
187
|
-
const
|
|
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
|
-
|
|
191
|
-
|
|
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 {
|
|
454
|
+
const { tools, unregister } = await syncSchema(state, entry)
|
|
195
455
|
if (state.disposed) {
|
|
196
456
|
unregister()
|
|
197
|
-
return
|
|
457
|
+
return record
|
|
198
458
|
}
|
|
199
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|