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/manage.js ADDED
@@ -0,0 +1,362 @@
1
+ // dsh-cc-mcp — management surface for the `/mcp` panel.
2
+ //
3
+ // This module owns everything the browser half needs and nothing that touches
4
+ // the MCP wire itself (that stays in register.js):
5
+ //
6
+ // - the stable server identity used by the panel, the persisted
7
+ // enable/disable set, and its JSON projections;
8
+ // - the persisted enable/disable store (the ONLY file this plugin writes —
9
+ // never a Claude Code config);
10
+ // - the Connection RPC endpoint handler behind the `/cc-mcp` channel, which
11
+ // DSH mounts behind its own Host/Origin fence and browser authentication;
12
+ // - the plain-text report behind the `/mcp` host command (CLI + `/mcp <args>`).
13
+
14
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
15
+ import { homedir } from 'node:os'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ /** RPC channel the browser half calls (DSH requires /^\/[A-Za-z0-9._~-]+$/). */
19
+ export const RPC_CHANNEL = '/cc-mcp'
20
+
21
+ /** Largest accepted request body; the panel only ever sends small JSON. */
22
+ const MAX_BODY_BYTES = 4096
23
+
24
+ /** One request path segment: the endpoint name. */
25
+ const ENDPOINT_PATTERN = /^[A-Za-z0-9_$.-]+$/
26
+
27
+ /** Shape version of the persisted state file. */
28
+ export const STATE_VERSION = 1
29
+
30
+ /**
31
+ * Default state-file location: `$DSH_HOME/cc-mcp-state.json`, falling back to
32
+ * `~/.dsh/cc-mcp-state.json` when the environment does not set DSH_HOME.
33
+ * @returns {string} absolute path
34
+ */
35
+ export function defaultStatePath() {
36
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
37
+ ? process.env.DSH_HOME
38
+ : join(homedir(), '.dsh')
39
+ return join(home, 'cc-mcp-state.json')
40
+ }
41
+
42
+ /**
43
+ * Identity of a host `@deepseek-ai/dsh-mcp-client` row. Disabling it hides the
44
+ * row's tools for the current workspace only — the host row itself is untouched.
45
+ * @param {string} serverName
46
+ * @returns {string}
47
+ */
48
+ export function hostKey(serverName) {
49
+ return `host:${serverName}`
50
+ }
51
+
52
+ /**
53
+ * Stable identity of one configured server. Project servers are unique per
54
+ * project config, plugin servers per (plugin, server) pair, host rows per
55
+ * server name — the same identity a disable decision must survive across
56
+ * sessions and restarts.
57
+ * @param {{serverName: string, pluginName?: string, hostRow?: boolean}} entry
58
+ * @returns {string}
59
+ */
60
+ export function serverKey(entry) {
61
+ if (entry.hostRow === true) return hostKey(entry.serverName)
62
+ return entry.pluginName
63
+ ? `plugin:${entry.pluginName}:${entry.serverName}`
64
+ : `project:${entry.serverName}`
65
+ }
66
+
67
+ /**
68
+ * Transport label used by the panel's Type column.
69
+ * @param {object} entry
70
+ * @returns {string}
71
+ */
72
+ export function transportOf(entry) {
73
+ const raw = typeof entry.transport === 'string' && entry.transport !== ''
74
+ ? entry.transport
75
+ : typeof entry.url === 'string' && entry.url !== ''
76
+ ? 'http'
77
+ : typeof entry.command === 'string' && entry.command !== ''
78
+ ? 'stdio'
79
+ : 'unknown'
80
+ return raw === 'streamable-http' || raw === 'streamableHttp' ? 'http' : raw
81
+ }
82
+
83
+ /** One JSON-safe server row for the panel. */
84
+ export function serverEntry(record) {
85
+ const tools = Array.isArray(record.tools) ? record.tools : []
86
+ const scope = record.entry.hostRow === true ? 'host' : record.entry.pluginName ? 'plugin' : 'project'
87
+ return {
88
+ key: record.key,
89
+ serverName: record.entry.serverName,
90
+ pluginName: record.entry.pluginName ?? null,
91
+ scope,
92
+ transport: transportOf(record.entry),
93
+ source: record.source ?? null,
94
+ status: record.status,
95
+ disabled: record.status === 'disabled',
96
+ // A host row the host layer itself connected (managed false) is listed and
97
+ // hidden/enabled here, but not re-registered by this plugin; a row this
98
+ // plugin adopted on demand is managed true.
99
+ managed: record.managed === true,
100
+ adoptable: record.adoptable === true,
101
+ rowId: record.entry.rowId ?? null,
102
+ toolPrefix: record.toolPrefix ?? null,
103
+ toolCount: tools.length,
104
+ tools: tools.map((tool) => ({
105
+ name: tool.name,
106
+ rawName: tool.rawName,
107
+ description: typeof tool.description === 'string' ? tool.description : '',
108
+ })),
109
+ error: record.error ?? null,
110
+ updatedAt: record.updatedAt ?? null,
111
+ }
112
+ }
113
+
114
+ /** Panel row order: project servers, then plugin servers, then host rows. */
115
+ const SCOPE_ORDER = { project: 0, plugin: 1, host: 2 }
116
+
117
+ /** Sort servers by scope, then name — the panel's stable row order. */
118
+ function compareServers(left, right) {
119
+ const byScope = (SCOPE_ORDER[left.scope] ?? 3) - (SCOPE_ORDER[right.scope] ?? 3)
120
+ if (byScope !== 0) return byScope
121
+ return left.serverName.localeCompare(right.serverName)
122
+ }
123
+
124
+ /**
125
+ * JSON snapshot of one session's MCP surface.
126
+ * @param {object} state - the per-agent controller.
127
+ * @returns {object}
128
+ */
129
+ export function snapshotOf(state) {
130
+ const servers = [...state.servers.values()].map(serverEntry).sort(compareServers)
131
+ return {
132
+ sessionId: String(state.agent.id),
133
+ known: true,
134
+ projectRoot: state.projectRoot ?? null,
135
+ sources: [...(state.sources ?? [])],
136
+ checked: state.checked === true,
137
+ servers,
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Snapshot for a session that has no live agent (or none this plugin wired):
143
+ * the panel renders its empty state from this instead of an error.
144
+ * @param {unknown} sessionId
145
+ * @returns {object}
146
+ */
147
+ export function unknownSnapshot(sessionId) {
148
+ return {
149
+ sessionId: sessionId === undefined || sessionId === null ? null : String(sessionId),
150
+ known: false,
151
+ projectRoot: null,
152
+ sources: [],
153
+ checked: true,
154
+ servers: [],
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Failures from the most recent check pass, in panel order.
160
+ * @param {object} state
161
+ * @returns {Array<{key: string, serverName: string, error: string}>}
162
+ */
163
+ export function failuresOf(state) {
164
+ return [...state.servers.values()]
165
+ .filter((record) => record.status === 'error' && typeof record.error === 'string' && record.error !== '')
166
+ .map((record) => ({ key: record.key, serverName: record.entry.serverName, error: record.error }))
167
+ .sort((left, right) => left.serverName.localeCompare(right.serverName))
168
+ }
169
+
170
+ /**
171
+ * Persisted enable/disable decisions. One JSON file, written atomically
172
+ * (temp file + rename) and only when a decision actually changes.
173
+ * @param {{path: string, log?: (level: string, message: string) => void}} options
174
+ */
175
+ export function createDisabledStore(options) {
176
+ const path = options.path
177
+ const log = options.log ?? (() => {})
178
+ let disabled = new Set()
179
+ let writeChain = Promise.resolve()
180
+
181
+ async function persist() {
182
+ const payload = `${JSON.stringify({ version: STATE_VERSION, disabled: [...disabled].sort() }, null, 2)}\n`
183
+ const temp = `${path}.tmp-${process.pid}`
184
+ await mkdir(dirname(path), { recursive: true })
185
+ await writeFile(temp, payload, 'utf8')
186
+ await rename(temp, path)
187
+ }
188
+
189
+ return {
190
+ path,
191
+ /** Read the file once; a missing or malformed file is an empty set, not a failure. */
192
+ async load() {
193
+ try {
194
+ const parsed = JSON.parse(await readFile(path, 'utf8'))
195
+ const list = Array.isArray(parsed?.disabled) ? parsed.disabled : []
196
+ disabled = new Set(list.filter((key) => typeof key === 'string' && key !== ''))
197
+ } catch (error) {
198
+ if (error?.code !== 'ENOENT') log('warn', `state file ${path} unreadable: ${String(error)} — starting empty`)
199
+ disabled = new Set()
200
+ }
201
+ return [...disabled]
202
+ },
203
+ has(key) {
204
+ return disabled.has(key)
205
+ },
206
+ list() {
207
+ return [...disabled].sort()
208
+ },
209
+ /**
210
+ * Record one decision, persisting only a real change.
211
+ * @returns {Promise<boolean>} whether the stored set changed.
212
+ */
213
+ async set(key, value) {
214
+ const changed = value === true ? !disabled.has(key) : disabled.has(key)
215
+ if (value === true) disabled.add(key)
216
+ else disabled.delete(key)
217
+ if (changed) {
218
+ writeChain = writeChain.then(persist, persist).catch((error) => {
219
+ log('error', `could not persist ${path}: ${String(error)}`)
220
+ })
221
+ await writeChain
222
+ }
223
+ return changed
224
+ },
225
+ }
226
+ }
227
+
228
+ /** `{ok:true,value}` success envelope for the Connection RPC transport. */
229
+ export function rpcOk(value) {
230
+ return { ok: true, value }
231
+ }
232
+
233
+ /** `{ok:false,error}` failure envelope; `details` must be a record. */
234
+ export function rpcFail(code, message, details = {}) {
235
+ return { ok: false, error: { code, message, details } }
236
+ }
237
+
238
+ /** One status word + qualifier for the text report. */
239
+ function statusText(entry) {
240
+ switch (entry.status) {
241
+ case 'ready': return `connected · ${entry.toolCount} tool(s)`
242
+ case 'disabled': return 'disabled — tools are not in the model context'
243
+ case 'skipped': return 'provided by a host MCP client'
244
+ case 'checking': return 'checking…'
245
+ default: return 'not connected'
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Plain-text report for the `/mcp` host command (CLI and `/mcp <args>`).
251
+ * @param {object} snapshot
252
+ * @returns {string}
253
+ */
254
+ export function formatTextReport(snapshot) {
255
+ const lines = [
256
+ `MCP servers (${snapshot.servers.length}) — ${snapshot.projectRoot ?? 'no project root resolved'}`,
257
+ ]
258
+ if (snapshot.servers.length === 0) {
259
+ lines.push(' (no MCP server configured for this session)')
260
+ }
261
+ for (const entry of snapshot.servers) {
262
+ const mark = entry.status === 'ready' ? '✓' : entry.status === 'disabled' ? '–' : entry.status === 'skipped' ? '»' : '✗'
263
+ lines.push(` ${mark} ${entry.serverName} [${entry.scope}/${entry.transport}] ${statusText(entry)}`)
264
+ if (entry.error !== null && entry.error !== undefined && entry.error !== '') {
265
+ lines.push(` ${entry.error}`)
266
+ }
267
+ }
268
+ lines.push('Open the interactive panel with /mcp in the Web GUI.')
269
+ return lines.join('\n')
270
+ }
271
+
272
+ // ─── plugin-owned HTTP transport ────────────────────────────────────────────
273
+ //
274
+ // The panel talks to this plugin over a route the plugin registers on the
275
+ // host's `webServer` service — the same pattern the shipped marketplace plugin
276
+ // uses (`ctx.inject(['webServer'], …) → hostCtx.webServer.register(…)`).
277
+ // The generic `connection` RPC service is NOT visible to a profile-level
278
+ // plugin, so it cannot carry this panel's traffic.
279
+
280
+ /** One JSON response with no caching. */
281
+ export function sendJson(response, status, payload) {
282
+ response.writeHead(status, {
283
+ 'cache-control': 'no-store',
284
+ 'content-type': 'application/json; charset=utf-8',
285
+ })
286
+ response.end(JSON.stringify(payload))
287
+ }
288
+
289
+ /** True when the request's Origin matches its Host — required on every POST. */
290
+ function sameOrigin(request) {
291
+ const origin = request.headers?.origin
292
+ const host = request.headers?.host
293
+ if (typeof origin !== 'string' || typeof host !== 'string') return false
294
+ try {
295
+ return new URL(origin).host === host
296
+ } catch {
297
+ return false
298
+ }
299
+ }
300
+
301
+ /** Read and parse a JSON request body, rejecting anything over the cap. */
302
+ async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
303
+ const chunks = []
304
+ let size = 0
305
+ for await (const chunk of request) {
306
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
307
+ size += buffer.length
308
+ if (size > maxBytes) throw new Error('request body too large')
309
+ chunks.push(buffer)
310
+ }
311
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
312
+ }
313
+
314
+ /**
315
+ * Build the `webServer` handler for the plugin's channel.
316
+ * @param {{dispatch: (endpoint: string, payload: object) => Promise<object>, log?: Function}} options
317
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
318
+ */
319
+ export function createRouteHandler(options) {
320
+ const dispatch = options.dispatch
321
+ const log = options.log ?? (() => {})
322
+ return async function handle(request, response) {
323
+ try {
324
+ const url = new URL(request.url ?? '/', 'http://localhost')
325
+ const endpoint = url.pathname.startsWith(`${RPC_CHANNEL}/`)
326
+ ? url.pathname.slice(RPC_CHANNEL.length + 1)
327
+ : ''
328
+ if (endpoint === '' || !ENDPOINT_PATTERN.test(endpoint)) {
329
+ sendJson(response, 404, rpcFail('cc-mcp/unknown-endpoint', `unknown endpoint ${JSON.stringify(url.pathname)}`))
330
+ return
331
+ }
332
+ const method = request.method ?? 'GET'
333
+ if (method !== 'GET' && method !== 'POST') {
334
+ response.writeHead(405, { allow: 'GET, POST' })
335
+ response.end()
336
+ return
337
+ }
338
+ if (method === 'POST' && !sameOrigin(request)) {
339
+ sendJson(response, 403, rpcFail('cc-mcp/untrusted-origin', 'the request Origin does not match its Host'))
340
+ return
341
+ }
342
+ let payload = {}
343
+ if (method === 'POST') {
344
+ try {
345
+ payload = await readJsonBody(request)
346
+ } catch (error) {
347
+ sendJson(response, 400, rpcFail('cc-mcp/bad-request', error instanceof Error ? error.message : String(error)))
348
+ return
349
+ }
350
+ }
351
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) payload = {}
352
+ sendJson(response, 200, await dispatch(endpoint, payload))
353
+ } catch (error) {
354
+ log('error', `management request failed: ${String(error)}`)
355
+ try {
356
+ sendJson(response, 500, rpcFail('cc-mcp/internal', error instanceof Error ? error.message : String(error)))
357
+ } catch {
358
+ /* the response is already gone; nothing left to report to */
359
+ }
360
+ }
361
+ }
362
+ }
package/src/register.js CHANGED
@@ -13,6 +13,10 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
13
13
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
14
14
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
15
15
 
16
+ // Carry this module's own cache-buster into manage.js, so a DSH hot reload of
17
+ // `…/src/index.js?v=N` never mixes a fresh entry with a stale helper module.
18
+ const { serverKey } = await import(`./manage.js${new URL(import.meta.url).search}`)
19
+
16
20
  export const MAX_PUBLIC_NAME_LENGTH = 64
17
21
  const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
18
22
  const HASH_LENGTH = 12
@@ -179,8 +183,12 @@ export function createDefinition(state, entry, rawName, publicName, tool) {
179
183
  parameters: tool.inputSchema,
180
184
  output: createOutput(rawName),
181
185
  async execute(args, exec) {
182
- const record = state.servers.get(entry.serverName)
186
+ const record = state.servers.get(serverKey(entry))
183
187
  if (record === undefined) throw new Error(`server ${entry.serverName} is no longer configured — reload the project config`)
188
+ if (record.status !== 'ready') {
189
+ const detail = typeof record.error === 'string' && record.error !== '' ? `: ${record.error}` : ''
190
+ throw new Error(`server ${entry.serverName} is not available (${record.status})${detail}`)
191
+ }
184
192
  const conn = await ensureConnected(state, record)
185
193
  conn.busy++
186
194
  try {
@@ -250,7 +258,7 @@ export async function openConnection(state, record) {
250
258
  }
251
259
  })
252
260
  conn.client = client
253
- if (state.disposed || state.servers.get(entry.serverName) !== record) {
261
+ if (state.disposed || state.servers.get(serverKey(entry)) !== record) {
254
262
  try { await client.close() } catch { /* best effort */ }
255
263
  throw new Error(`agent disposed or config reloaded during connect`)
256
264
  }