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/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
|
|
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
|
|
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
|
}
|