dsh-cc-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # dsh-cc-mcp
2
+
3
+ Load Claude Code MCP server configs into DeepSeek Harness as **runtime-registered
4
+ tools** — no config-row writes, no restarts for config changes.
5
+
6
+ 把 Claude Code 的 MCP 配置(`.mcp.json` / plugin.json 内联 `mcpServers`)以运行时注册工具的形式加载进 DSH。
7
+
8
+ ## What it does
9
+
10
+ | Source | Tool namespace | Notes |
11
+ |---|---|---|
12
+ | Project root `.mcp.json` | `mcp__<server>__<tool>` | DSH-native name, same as `dsh-mcp-client` rows |
13
+ | Plugin root `.mcp.json` / `plugin.json` inline | `mcp__plugin_<name>_<server>__<tool>` | Official CC plugin naming (e.g. `mcp__plugin_asana_asana__asana_create_task`) |
14
+
15
+ - **Dual-form parsing** (shared `dsh-cc-loader`): accepts both the
16
+ `{"mcpServers": {...}}` wrapper (project level / community plugins) and the
17
+ bare server map (official plugin form).
18
+ - **Transports**: `stdio` (command) and `http` (url → streamable-http) register
19
+ as tools. `sse` / `ws` are reported as unsupported (DSH has no such
20
+ transport) and never crash anything.
21
+ - **Env secrets stay out of configs**: `${NAME}` placeholders are kept verbatim
22
+ in the IR and expanded at runtime from `process.env`; `${CLAUDE_PLUGIN_ROOT}`
23
+ expands to the plugin directory. Nothing is written to disk.
24
+ - **Lazy connections**: tool schemas register eagerly (the model must see the
25
+ list), the actual server process connects on first call, and disconnects
26
+ after `idleTimeoutMs` without calls. Dead connections reconnect on the next
27
+ call.
28
+ - **Hot reload**: editing the project `.mcp.json` rebuilds running sessions'
29
+ project MCP surface (~1 s).
30
+ - **Conflict semantics**: a server already provided by a preset/host
31
+ `dsh-mcp-client` row is skipped by default; `"override": true` in the server
32
+ entry forces the project/plugin connection (agent layer shadows upper
33
+ layers).
34
+
35
+ ## Trust model ⚠️
36
+
37
+ `.mcp.json` is **executable project content** — the same trust as
38
+ `package.json` scripts. Installing this plugin enables that. Children run with a
39
+ scrubbed environment (credential-shaped and stale `DSH_*` variables dropped),
40
+ mirroring the official bridge.
41
+
42
+ ## Install (dev / local checkout)
43
+
44
+ ```sh
45
+ # in packages/cc-mcp: install deps (official MCP SDK + shared loader)
46
+ npm install
47
+
48
+ # user patch layer (hot, no bundle reconcile):
49
+ # append to ~/.dsh/profiles/<profile>/cordis.patch.yml
50
+ - insert:
51
+ - id: cc-mcp
52
+ name: 'dsh-cc-mcp'
53
+ config:
54
+ enableProject: true
55
+ pluginRoots: [] # absolute paths to plugin dirs (future M4 feeds this)
56
+ idleTimeoutMs: 300000
57
+ toolCallTimeoutMs: 60000
58
+ watchProject: true
59
+ ```
60
+
61
+ Windows absolute paths in `pluginRoots` need `file:///`? No — these are plain
62
+ directory paths inside the config, not module specifiers.
63
+
64
+ ## Config
65
+
66
+ | Field | Default | Meaning |
67
+ |---|---|---|
68
+ | `enableProject` | `true` | read `<projectRoot>/.mcp.json` per session |
69
+ | `pluginRoots` | `[]` | extra plugin directories to scan (`.mcp.json` + `plugin.json`) |
70
+ | `idleTimeoutMs` | `300000` | idle disconnect (0 = never) |
71
+ | `toolCallTimeoutMs` | `60000` | per-call timeout |
72
+ | `watchProject` | `true` | hot-reload the project `.mcp.json` |
73
+
74
+ ## Example
75
+
76
+ `<project>/.mcp.json`:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "github": {
82
+ "command": "npx",
83
+ "args": ["-y", "@modelcontextprotocol/server-github"],
84
+ "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Sessions in that project get `mcp__github__create_issue` etc. on first tool
91
+ list assembly.
92
+
93
+ ## License
94
+
95
+ MIT — runtime registration pattern ported from
96
+ [dsh-project-mcp-bridge](https://github.com/KYinCode/dsh-project-mcp-bridge)
97
+ (MIT, © KYinCode); parsing lives in `dsh-cc-loader`.
@@ -0,0 +1,9 @@
1
+ - insert:
2
+ - id: cc-mcp
3
+ name: 'dsh-cc-mcp'
4
+ config:
5
+ enableProject: true
6
+ pluginRoots: []
7
+ idleTimeoutMs: 300000
8
+ toolCallTimeoutMs: 60000
9
+ watchProject: true
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "dsh-cc-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Load Claude Code MCP server configs (.mcp.json / plugin.json inline mcpServers) into DSH as runtime-registered tools: project-root .mcp.json → mcp__<server>__<tool>, plugin MCP → mcp__plugin_<name>_<server>__<tool> (official CC naming). Client bridge on the official @modelcontextprotocol/sdk — no config-row writes, zero-write path preserved.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "dsh": {
11
+ "bundle": {
12
+ "patch": "./cordis.patch.yml"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "cordis.patch.yml"
19
+ ],
20
+ "keywords": [
21
+ "dsh",
22
+ "deepseek-harness",
23
+ "claude-code",
24
+ "mcp",
25
+ "claude"
26
+ ],
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.12.0",
30
+ "dsh-cc-loader": "^0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@deepseek-ai/cordis": "4.0.1",
34
+ "@deepseek-ai/dsh-subprocess": "0.1.0-rc.6",
35
+ "@deepseek-ai/schemastery": "3.18.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/cordis": "*",
39
+ "@deepseek-ai/dsh-subprocess": "*",
40
+ "@deepseek-ai/schemastery": "*"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@deepseek-ai/cordis": {
44
+ "optional": true
45
+ },
46
+ "@deepseek-ai/dsh-subprocess": {
47
+ "optional": true
48
+ },
49
+ "@deepseek-ai/schemastery": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "repository": "git+https://github.com/Bcy2020/dsh-cc-ecosystem.git",
57
+ "homepage": "https://github.com/Bcy2020/dsh-cc-ecosystem"
58
+ }
package/src/index.js ADDED
@@ -0,0 +1,334 @@
1
+ // dsh-cc-mcp — load Claude Code MCP configs into DSH as runtime-registered
2
+ // tools, on the official @modelcontextprotocol/sdk.
3
+ //
4
+ // Sources (parsed by the shared dsh-cc-loader):
5
+ // - project root .mcp.json → mcp__<server>__<tool> (DSH-native)
6
+ // - plugin roots (config.pluginRoots) → mcp__plugin_<name>_<server>__<tool>
7
+ // (official CC plugin naming), from each plugin's .mcp.json / plugin.json
8
+ //
9
+ // Modeled on dsh-project-mcp-bridge (MIT): tools register eagerly into the
10
+ // agent scope layer (agent/created), connections are lazy with an idle
11
+ // timeout, and project .mcp.json changes hot-reload. Env values are expanded
12
+ // at runtime from process.env — nothing is written to disk.
13
+ //
14
+ // Safety: apply() never throws synchronously; per-agent failures are logged,
15
+ // never fatal to the host.
16
+
17
+ import { readFile, mkdir, appendFile } from 'node:fs/promises'
18
+ import { watchFile, unwatchFile } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import Schema from '@deepseek-ai/schemastery'
21
+ import { findProjectRoot, discoverProjectMcp, discoverPluginRoot } from 'dsh-cc-loader'
22
+
23
+ // Re-exported for callers that need the dir-basename fallback name without a
24
+ // manifest (discoverPluginRoot prefers manifest `name`; pluginNameOf is the
25
+ // no-manifest fallback).
26
+ export { pluginNameOf } from 'dsh-cc-loader'
27
+ import {
28
+ publicToolName, connectAndList, createDefinition, clearIdle,
29
+ } from './register.js'
30
+
31
+ export const name = 'dsh-cc-mcp'
32
+ export const inject = []
33
+
34
+ export const Config = Schema.object({
35
+ enableProject: Schema.boolean().default(true),
36
+ pluginRoots: Schema.array(Schema.string()).default([]),
37
+ idleTimeoutMs: Schema.number().default(300000),
38
+ toolCallTimeoutMs: Schema.number().default(60000),
39
+ watchProject: Schema.boolean().default(true),
40
+ })
41
+
42
+ const DEFAULT_CALL_TIMEOUT_MS = 60000
43
+ const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000
44
+ const WATCH_DEBOUNCE_MS = 300
45
+
46
+ // Per-agent live controllers; module-level because agent/created wiring must
47
+ // outlive one listener call. Cleanup on agent/disposed and plugin unload.
48
+ const agentStates = new Map()
49
+ const projectWatchers = new Map() // projectRoot -> { controllers: Set, timer }
50
+
51
+ function log(ctx, level, message) {
52
+ try { ctx.logger?.[level]?.(`dsh-cc-mcp: ${message}`) } catch { /* logger absence is not fatal */ }
53
+ }
54
+
55
+ // ─── per-agent controller ────────────────────────────────────────────────────
56
+
57
+ function createController(ctx, agent, agentCtx, baseCwd, config) {
58
+ return {
59
+ ctx, agent, agentCtx, baseCwd, config,
60
+ servers: new Map(),
61
+ queue: Promise.resolve(),
62
+ disposed: false,
63
+ watcherRef: null,
64
+ }
65
+ }
66
+
67
+ function enqueue(state, fn) {
68
+ state.queue = state.queue.then(fn).catch((error) => {
69
+ if (!state.disposed) log(state.ctx, 'error', `agent ${state.agent.id}: ${String(error)}`)
70
+ })
71
+ return state.queue
72
+ }
73
+
74
+ function cleanupState(ctx, state, reason) {
75
+ if (state.disposed) return
76
+ state.disposed = true
77
+ agentStates.delete(state.agent.id)
78
+ for (const record of [...state.servers.values()]) teardownServer(state, record, reason).catch(() => {})
79
+ detachWatcher(state)
80
+ log(ctx, 'info', `agent ${state.agent.id} (${state.baseCwd}): cleanup (${reason})`)
81
+ }
82
+
83
+ // ─── discovery → normalized entries ─────────────────────────────────────────
84
+
85
+ /** ${CLAUDE_PLUGIN_ROOT} expansion for plugin entries; project entries untouched. */
86
+ function expandEntry(entry, pluginRoot) {
87
+ if (!pluginRoot) return entry
88
+ const command = entry.command?.includes('${CLAUDE_PLUGIN_ROOT}')
89
+ ? entry.command.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) : entry.command
90
+ const args = entry.args.map((a) => a.includes('${CLAUDE_PLUGIN_ROOT}')
91
+ ? a.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) : a)
92
+ const cwd = entry.cwd?.includes('${CLAUDE_PLUGIN_ROOT}')
93
+ ? entry.cwd.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) : entry.cwd
94
+ return { ...entry, command, args, cwd }
95
+ }
96
+
97
+ /** Fold loader entries into bridge-style runtime entries (defaults + expansion). */
98
+ export function toRuntimeEntries(servers, opts = {}) {
99
+ const { pluginRoot, idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, toolCallTimeoutMs = DEFAULT_CALL_TIMEOUT_MS } = opts
100
+ const out = []
101
+ for (const s of servers) {
102
+ if (s.status !== 'DIRECT') continue // UNSUPPORTED (sse/ws) reported, not registered
103
+ out.push({
104
+ ...expandEntry(s, pluginRoot),
105
+ toolCallTimeoutMs: s.toolCallTimeoutMs ?? toolCallTimeoutMs,
106
+ idleTimeoutMs: s.idleTimeoutMs ?? idleTimeoutMs,
107
+ })
108
+ }
109
+ return out
110
+ }
111
+
112
+ async function collectEntries(state) {
113
+ const cfg = state.config
114
+ const entries = []
115
+ const sources = []
116
+ if (cfg.enableProject && state.projectRoot !== undefined) {
117
+ const found = await discoverProjectMcp(state.projectRoot, { warn: (m) => log(state.ctx, 'warn', `agent ${state.agent.id}: ${m}`) })
118
+ entries.push(...toRuntimeEntries(found.servers, { idleTimeoutMs: cfg.idleTimeoutMs, toolCallTimeoutMs: cfg.toolCallTimeoutMs }))
119
+ sources.push(...found.sources)
120
+ }
121
+ for (const root of cfg.pluginRoots) {
122
+ // discoverPluginRoot inventories the whole plugin (M4): manifest name
123
+ // wins over the directory name for the tool namespace, and manifest
124
+ // `mcpServers` path/inline configs are included alongside .mcp.json.
125
+ const plugin = await discoverPluginRoot(root, { warn: (m) => log(state.ctx, 'warn', `agent ${state.agent.id}: ${m}`) })
126
+ entries.push(...toRuntimeEntries(plugin.components.mcp.servers, {
127
+ pluginRoot: root,
128
+ idleTimeoutMs: cfg.idleTimeoutMs,
129
+ toolCallTimeoutMs: cfg.toolCallTimeoutMs,
130
+ }))
131
+ sources.push(...plugin.components.mcp.sources)
132
+ }
133
+ return { entries, sources }
134
+ }
135
+
136
+ // ─── schema sync + registration ──────────────────────────────────────────────
137
+
138
+ function hasUpperServerTools(state, prefix) {
139
+ try {
140
+ const schemas = state.agentCtx.tools.schemas(state.agent)
141
+ return Array.isArray(schemas) && schemas.some((s) => typeof s.name === 'string' && s.name.startsWith(prefix))
142
+ } catch {
143
+ return false
144
+ }
145
+ }
146
+
147
+ async function syncSchema(state, entry) {
148
+ const { client, tools } = await connectAndList(entry, state.baseCwd)
149
+ try {
150
+ const definitions = new Map()
151
+ const prefix = entry.pluginName ? `mcp__plugin_${entry.pluginName}_${entry.serverName}__` : `mcp__${entry.serverName}__`
152
+ for (const tool of tools) {
153
+ const publicName = publicToolName(
154
+ { serverName: entry.serverName, pluginName: entry.pluginName },
155
+ tool.name,
156
+ )
157
+ if (definitions.has(publicName)) {
158
+ throw new Error(`server listed tool "${tool.name}" more than once — invalid tool list`)
159
+ }
160
+ definitions.set(publicName, createDefinition(state, entry, tool.name, publicName, tool))
161
+ }
162
+ const disposers = []
163
+ for (const [publicName, definition] of definitions) {
164
+ try {
165
+ disposers.push(state.agentCtx.tools.register(definition))
166
+ } catch (error) {
167
+ log(state.ctx, 'error', `agent ${state.agent.id}: registering ${publicName} failed: ${String(error)}`)
168
+ }
169
+ }
170
+ return {
171
+ toolCount: disposers.length,
172
+ prefix,
173
+ unregister: () => {
174
+ for (const dispose of disposers) {
175
+ try { dispose() } catch { /* best effort */ }
176
+ }
177
+ },
178
+ }
179
+ } finally {
180
+ try { await client.close() } catch { /* best effort */ }
181
+ }
182
+ }
183
+
184
+ async function setupServer(state, entry) {
185
+ const serverName = entry.serverName
186
+ const prefix = entry.pluginName ? `mcp__plugin_${entry.pluginName}_${serverName}__` : `mcp__${serverName}__`
187
+ const upper = hasUpperServerTools(state, prefix)
188
+ if (!entry.override && upper) {
189
+ log(state.ctx, 'info', `agent ${state.agent.id}: server ${serverName} already provided by preset/host MCP — skipped (set "override": true to force)`)
190
+ return
191
+ }
192
+ try {
193
+ const { toolCount, unregister } = await syncSchema(state, entry)
194
+ if (state.disposed) {
195
+ unregister()
196
+ return
197
+ }
198
+ state.servers.set(serverName, { entry, unregister, conn: null, connecting: null })
199
+ const shadowed = upper ? ' (agent layer shadows upper-layer registration(s))' : ''
200
+ log(state.ctx, 'info', `agent ${state.agent.id}: registered ${toolCount} tool(s) from server ${serverName}${shadowed}`)
201
+ } catch (error) {
202
+ log(state.ctx, 'error', `agent ${state.agent.id}: server ${serverName} not loaded: ${String(error)}`)
203
+ }
204
+ }
205
+
206
+ async function teardownServer(state, record, reason) {
207
+ state.servers.delete(record.entry.serverName)
208
+ try { record.unregister() } catch { /* best effort */ }
209
+ const conn = record.conn
210
+ if (conn !== null) {
211
+ record.conn = null
212
+ clearIdle(conn)
213
+ try { await conn.client.close() } catch { /* best effort */ }
214
+ }
215
+ log(state.ctx, 'info', `agent ${state.agent.id}: server ${record.entry.serverName}: ${reason}`)
216
+ }
217
+
218
+ // ─── config application (initial load + hot reload share one path) ──────────
219
+
220
+ async function applyConfig(state) {
221
+ if (state.disposed) return
222
+ const { entries } = await collectEntries(state)
223
+ for (const record of [...state.servers.values()]) {
224
+ await teardownServer(state, record, 'config change — rebuilding')
225
+ }
226
+ for (const entry of entries) {
227
+ await setupServer(state, entry)
228
+ }
229
+ }
230
+
231
+ async function reloadFromDisk(state) {
232
+ if (state.disposed) return
233
+ try {
234
+ await applyConfig(state)
235
+ } catch (error) {
236
+ log(state.ctx, 'error', `agent ${state.agent.id}: reload failed: ${String(error)}`)
237
+ }
238
+ }
239
+
240
+ // ─── watcher (project .mcp.json hot reload) ──────────────────────────────────
241
+
242
+ function attachWatcher(state) {
243
+ if (!state.config.watchProject || state.projectRoot === undefined) return
244
+ const root = state.projectRoot
245
+ let w = projectWatchers.get(root)
246
+ if (w === undefined) {
247
+ w = { controllers: new Set(), timer: null }
248
+ projectWatchers.set(root, w)
249
+ const configPath = join(root, '.mcp.json')
250
+ const onChange = () => {
251
+ if (w.timer !== null) clearTimeout(w.timer)
252
+ w.timer = setTimeout(() => {
253
+ w.timer = null
254
+ for (const controller of [...w.controllers]) {
255
+ if (!controller.disposed) enqueue(controller, () => reloadFromDisk(controller))
256
+ }
257
+ }, WATCH_DEBOUNCE_MS)
258
+ }
259
+ try {
260
+ watchFile(configPath, { interval: 500 }, onChange)
261
+ } catch (error) {
262
+ log(state.ctx, 'warn', `watch ${configPath} failed: ${String(error)} — hot reload disabled`)
263
+ }
264
+ }
265
+ w.controllers.add(state)
266
+ state.watcherRef = w
267
+ }
268
+
269
+ function detachWatcher(state) {
270
+ const w = state.watcherRef
271
+ if (w === undefined) return
272
+ state.watcherRef = undefined
273
+ w.controllers.delete(state)
274
+ if (w.controllers.size === 0 && state.projectRoot !== undefined) {
275
+ if (w.timer !== null) clearTimeout(w.timer)
276
+ try { unwatchFile(join(state.projectRoot, '.mcp.json')) } catch { /* best effort */ }
277
+ projectWatchers.delete(state.projectRoot)
278
+ }
279
+ }
280
+
281
+ // ─── agent wiring ────────────────────────────────────────────────────────────
282
+
283
+ function agentCwd(agent) {
284
+ return (agent && agent.session && agent.session.header && agent.session.header.cwd) ||
285
+ (agent && agent.header && agent.header.cwd) ||
286
+ undefined
287
+ }
288
+
289
+ async function wireAgent(ctx, agent, config) {
290
+ const cwd = agentCwd(agent)
291
+ if (!cwd) {
292
+ log(ctx, 'warn', `agent ${agent.id}: no session cwd found — project MCP skipped`)
293
+ return
294
+ }
295
+ const agentCtx = agent.ctx
296
+ if (!agentCtx || typeof agentCtx.tools?.register !== 'function' || typeof agentCtx.tools?.schemas !== 'function') {
297
+ log(ctx, 'warn', `agent ${agent.id} (${cwd}): agent tools service unavailable — project MCP skipped`)
298
+ return
299
+ }
300
+ const state = createController(ctx, agent, agentCtx, cwd, config)
301
+ const projectRoot = config.enableProject ? await findProjectRoot(cwd) : undefined
302
+ state.projectRoot = projectRoot
303
+ agentStates.set(agent.id, state)
304
+ attachWatcher(state)
305
+ enqueue(state, () => reloadFromDisk(state))
306
+ }
307
+
308
+ export function apply(ctx, config) {
309
+ // apply() must never throw synchronously: a throw here kills the host.
310
+ const wire = async (payload) => {
311
+ const agent = payload && payload.agent
312
+ if (!agent) return
313
+ try {
314
+ await wireAgent(ctx, agent, config)
315
+ } catch (error) {
316
+ log(ctx, 'error', `agent ${agent.id}: unexpected failure: ${String(error)}`)
317
+ }
318
+ }
319
+ try {
320
+ ctx.on('agent/created', (payload) => { void wire(payload) })
321
+ ctx.on('agent/disposed', (payload) => {
322
+ const agent = payload && payload.agent
323
+ if (!agent) return
324
+ const state = agentStates.get(agent.id)
325
+ if (state !== undefined) cleanupState(ctx, state, 'agent disposed')
326
+ })
327
+ ctx.effect(() => () => {
328
+ for (const state of [...agentStates.values()]) cleanupState(ctx, state, 'plugin reloaded')
329
+ })
330
+ log(ctx, 'info', `plugin active — project .mcp.json ${config.enableProject ? 'enabled' : 'disabled'}, ${config.pluginRoots.length} plugin root(s)`)
331
+ } catch (error) {
332
+ log(ctx, 'error', `apply failed: ${String(error)}`)
333
+ }
334
+ }
@@ -0,0 +1,266 @@
1
+ // dsh-cc-mcp — tool definition / MCP client plumbing (ported from
2
+ // dsh-project-mcp-bridge, MIT, KYinCode, with plugin-namespace support).
3
+ //
4
+ // All functions here are pure-ish (no plugin wiring): they build tool
5
+ // definitions from MCP tool schemas and drive the official MCP SDK client.
6
+ // Env values are expanded at runtime from process.env (never inlined into
7
+ // any config); `${CLAUDE_PLUGIN_ROOT}` expands to the plugin directory.
8
+
9
+ import { createHash } from 'node:crypto'
10
+ import { isAbsolute, join } from 'node:path'
11
+ import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
12
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
13
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
14
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
15
+
16
+ export const MAX_PUBLIC_NAME_LENGTH = 64
17
+ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
18
+ const HASH_LENGTH = 12
19
+
20
+ /**
21
+ * CC tool naming: plugin MCP → mcp__plugin_<plugin>_<server>__<tool>;
22
+ * project MCP → mcp__<server>__<tool> (DSH-native). Normalized per the
23
+ * DeepSeek name contract: illegal chars → `_`, overlong → sha256 hash suffix.
24
+ * @param {{serverName: string, pluginName?: string}} scope
25
+ * @param {string} rawName
26
+ * @returns {string}
27
+ */
28
+ export function publicToolName(scope, rawName) {
29
+ const prefix = scope.pluginName
30
+ ? `mcp__plugin_${scope.pluginName}_${scope.serverName}__`
31
+ : `mcp__${scope.serverName}__`
32
+ const joined = prefix + rawName
33
+ const normalized = joined.replace(INVALID_NAME_CHARS, '_')
34
+ if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
35
+ const hash = createHash('sha256').update(`${prefix}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
36
+ return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
37
+ }
38
+
39
+ /** Replace ${NAME} with process.env.NAME; missing vars are left verbatim and warned. */
40
+ export function interpolateEnv(value, warn) {
41
+ return String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, key) => {
42
+ const v = process.env[key]
43
+ if (v === undefined) {
44
+ warn(`environment variable ${key} is not set`)
45
+ return match
46
+ }
47
+ return v
48
+ })
49
+ }
50
+
51
+ /**
52
+ * Expand ${CLAUDE_PLUGIN_ROOT} (plugin dir) in a command/arg/cwd string.
53
+ * @param {string} value
54
+ * @param {string} pluginRoot - absolute plugin directory
55
+ * @returns {string}
56
+ */
57
+ export function expandPluginRoot(value, pluginRoot) {
58
+ if (!value.includes('${CLAUDE_PLUGIN_ROOT}')) return value
59
+ return value.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot)
60
+ }
61
+
62
+ function createTransport(entry, baseCwd) {
63
+ if (entry.transport === 'stdio') {
64
+ const cwd = entry.cwd === undefined
65
+ ? baseCwd
66
+ : isAbsolute(entry.cwd) ? entry.cwd : join(baseCwd, entry.cwd)
67
+ const warn = () => {}
68
+ const env = {}
69
+ for (const [k, v] of Object.entries(entry.env ?? {})) env[k] = interpolateEnv(v, warn)
70
+ return new StdioClientTransport({
71
+ command: entry.command,
72
+ args: entry.args,
73
+ // Privilege reduction (mirrors the official bridge): credential-shaped
74
+ // and stale DSH_* variables dropped, then the config's explicit env on top.
75
+ env: { ...scrubbedParentEnv(), ...env },
76
+ cwd,
77
+ })
78
+ }
79
+ return new StreamableHTTPClientTransport(new URL(entry.url), {
80
+ headers: Object.fromEntries(
81
+ Object.entries(entry.headers ?? {}).map(([k, v]) => [k, interpolateEnv(v, () => {})]),
82
+ ),
83
+ })
84
+ }
85
+
86
+ /** Connect a fresh client and drain tools/list; closes it on failure. */
87
+ export async function connectAndList(entry, baseCwd, onClose) {
88
+ const client = new Client({ name: 'dsh-cc-mcp', version: '0.1.0' })
89
+ const transport = createTransport(entry, baseCwd)
90
+ let closed = false
91
+ client.onclose = () => {
92
+ closed = true
93
+ if (onClose) onClose()
94
+ }
95
+ try {
96
+ await client.connect(transport)
97
+ const tools = []
98
+ let cursor
99
+ do {
100
+ const page = await client.listTools({ cursor })
101
+ tools.push(...page.tools)
102
+ cursor = page.nextCursor
103
+ } while (cursor)
104
+ return { client, tools, closed }
105
+ } catch (error) {
106
+ try { await client.close() } catch { /* best effort */ }
107
+ throw error
108
+ }
109
+ }
110
+
111
+ /** MCP content blocks → model-facing text (mirrors dsh-mcp-client). */
112
+ export function extractText(mcpContent) {
113
+ const parts = []
114
+ for (const value of mcpContent) {
115
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
116
+ parts.push('[unsupported content type: unknown]')
117
+ continue
118
+ }
119
+ switch (value.type) {
120
+ case 'text':
121
+ parts.push(typeof value.text === 'string' ? value.text : '')
122
+ break
123
+ case 'image':
124
+ parts.push(`[image: ${value.mimeType ?? 'unknown'}]`)
125
+ break
126
+ case 'audio':
127
+ parts.push(`[audio: ${value.mimeType ?? 'unknown'}]`)
128
+ break
129
+ case 'resource':
130
+ parts.push(`[resource: ${value.resource?.uri ?? 'unknown'}]`)
131
+ break
132
+ default:
133
+ parts.push(`[unsupported content type: ${value.type ?? 'unknown'}]`)
134
+ }
135
+ }
136
+ return parts.join('\n')
137
+ }
138
+
139
+ /** callTool result → model-facing content (isError results throw). */
140
+ export function mapResult(result) {
141
+ if (!Array.isArray(result.content)) {
142
+ const rendered = 'toolResult' in result ? JSON.stringify(result.toolResult) : '(no output)'
143
+ const text = typeof rendered === 'string' ? rendered : '(no output)'
144
+ if (result.isError === true) throw new Error(text)
145
+ return { content: [{ type: 'text', text }], ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}) }
146
+ }
147
+ const text = extractText(result.content)
148
+ if (result.isError === true) throw new Error(text)
149
+ return { content: result.content, ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}) }
150
+ }
151
+
152
+ /** Build the canonical output declaration (structured content falls back to JsonValue). */
153
+ export function createOutput(rawName) {
154
+ return {
155
+ schema: {
156
+ type: 'object',
157
+ properties: {
158
+ content: { type: 'array', items: {} },
159
+ structuredContent: {},
160
+ },
161
+ required: ['content'],
162
+ additionalProperties: false,
163
+ },
164
+ render(_args, value) {
165
+ return [{ type: 'text', text: extractText(value.content) }]
166
+ },
167
+ }
168
+ }
169
+
170
+ /**
171
+ * One tool definition. `execute` lazily connects (first call) and re-arms the
172
+ * per-connection idle timer around the call. `state.servers` holds the live
173
+ * records; see the controller in index.js.
174
+ */
175
+ export function createDefinition(state, entry, rawName, publicName, tool) {
176
+ return {
177
+ name: publicName,
178
+ description: typeof tool.description === 'string' ? tool.description : '',
179
+ parameters: tool.inputSchema,
180
+ output: createOutput(rawName),
181
+ async execute(args, exec) {
182
+ const record = state.servers.get(entry.serverName)
183
+ if (record === undefined) throw new Error(`server ${entry.serverName} is no longer configured — reload the project config`)
184
+ const conn = await ensureConnected(state, record)
185
+ conn.busy++
186
+ try {
187
+ armIdle(state, record, conn)
188
+ const signal = exec && exec.signal
189
+ ? AbortSignal.any([exec.signal, AbortSignal.timeout(record.entry.toolCallTimeoutMs)])
190
+ : AbortSignal.timeout(record.entry.toolCallTimeoutMs)
191
+ const result = await conn.client.callTool(
192
+ { name: rawName, arguments: typeof args === 'object' && args !== null ? args : {} },
193
+ undefined,
194
+ { signal },
195
+ )
196
+ return mapResult(result)
197
+ } finally {
198
+ conn.busy--
199
+ armIdle(state, record, conn)
200
+ }
201
+ },
202
+ }
203
+ }
204
+
205
+ // ─── connection lifecycle helpers (shared with the controller) ──────────────
206
+
207
+ const MAX_TIMER_DELAY_MS = 0x7fffffff
208
+
209
+ export function armIdle(state, record, conn) {
210
+ clearIdle(conn)
211
+ if (conn.busy > 0) return
212
+ const timeout = record.entry.idleTimeoutMs
213
+ if (timeout <= 0) return
214
+ conn.idleTimer = setTimeout(() => {
215
+ conn.idleTimer = null
216
+ if (record.conn !== conn) return
217
+ record.conn = null
218
+ conn.client.close().catch(() => {})
219
+ }, Math.min(timeout, MAX_TIMER_DELAY_MS))
220
+ conn.idleTimer.unref?.()
221
+ }
222
+
223
+ export function clearIdle(conn) {
224
+ if (conn.idleTimer !== null) {
225
+ clearTimeout(conn.idleTimer)
226
+ conn.idleTimer = null
227
+ }
228
+ }
229
+
230
+ /** Get the live connection for a record, connecting lazily when absent. */
231
+ export function ensureConnected(state, record) {
232
+ if (record.conn !== null) return Promise.resolve(record.conn)
233
+ if (record.connecting === null) {
234
+ const attempt = openConnection(state, record)
235
+ record.connecting = attempt
236
+ attempt.catch(() => {}).finally(() => {
237
+ if (record.connecting === attempt) record.connecting = null
238
+ })
239
+ }
240
+ return record.connecting
241
+ }
242
+
243
+ export async function openConnection(state, record) {
244
+ const entry = record.entry
245
+ const conn = { client: null, idleTimer: null, busy: 0 }
246
+ const { client, closed } = await connectAndList(entry, state.baseCwd, () => {
247
+ if (record.conn === conn) {
248
+ record.conn = null
249
+ clearIdle(conn)
250
+ }
251
+ })
252
+ conn.client = client
253
+ if (state.disposed || state.servers.get(entry.serverName) !== record) {
254
+ try { await client.close() } catch { /* best effort */ }
255
+ throw new Error(`agent disposed or config reloaded during connect`)
256
+ }
257
+ record.conn = conn
258
+ if (closed) {
259
+ record.conn = null
260
+ clearIdle(conn)
261
+ try { await client.close() } catch { /* best effort */ }
262
+ throw new Error(`connection closed during connect`)
263
+ }
264
+ armIdle(state, record, conn)
265
+ return conn
266
+ }