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 +97 -0
- package/cordis.patch.yml +9 -0
- package/package.json +58 -0
- package/src/index.js +334 -0
- package/src/register.js +266 -0
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`.
|
package/cordis.patch.yml
ADDED
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
|
+
}
|
package/src/register.js
ADDED
|
@@ -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
|
+
}
|