dsh-mcp-plus 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/LICENSE +21 -0
- package/README.md +149 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.iife.js +497 -0
- package/lib/index.mjs +328 -0
- package/package.json +62 -0
- package/scripts/install.mjs +105 -0
- package/src/client.ts +482 -0
- package/src/config.ts +108 -0
- package/src/dsh-mcp-plus.ts +197 -0
- package/src/http.ts +116 -0
- package/src/servers-store.ts +44 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// dsh-mcp-plus:增强型 MCP 客户端插件
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
// ContentBlock:给 AI 看的内容块类型(text/图片等)
|
|
4
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
5
|
+
// MCP 官方 SDK
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
8
|
+
// 配置形状 + 配置文件读写 + HTTP 端点(本插件的三个子模块)
|
|
9
|
+
import type { ServerConfig } from './config'
|
|
10
|
+
import { loadServers } from './servers-store'
|
|
11
|
+
import { registerHttpRoutes, setConnectionStatus, clearConnectionStatus } from './http'
|
|
12
|
+
|
|
13
|
+
// 插件名(显示在日志/诊断里)
|
|
14
|
+
export const name = 'dsh-mcp-plus'
|
|
15
|
+
|
|
16
|
+
// 声明依赖:
|
|
17
|
+
// tools → 注册工具给 AI
|
|
18
|
+
// webServer → 注册 HTTP 端点(设置 UI 的读写通道)
|
|
19
|
+
export const inject = ['tools', 'webServer']
|
|
20
|
+
|
|
21
|
+
// ---- 命名空间:多服务器不打架的关键 ----
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 生成模型可见的公开工具名:`mcp__<serverName>__<rawName>`(官方同款格式)
|
|
25
|
+
* 例:serverName=fs, rawName=read_file → mcp__fs__read_file
|
|
26
|
+
*/
|
|
27
|
+
function publicToolName(serverName: string, rawName: string): string {
|
|
28
|
+
return `mcp__${serverName}__${rawName}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 连接一台 MCP 服务器,发现工具并全部注册进 ctx.tools
|
|
33
|
+
* @returns 清理函数:断开连接
|
|
34
|
+
*/
|
|
35
|
+
async function connectServer(ctx: Context, cfg: ServerConfig): Promise<() => Promise<void>> {
|
|
36
|
+
const transport = new StdioClientTransport({
|
|
37
|
+
command: cfg.command,
|
|
38
|
+
args: cfg.args,
|
|
39
|
+
env: cfg.env,
|
|
40
|
+
cwd: cfg.cwd,
|
|
41
|
+
})
|
|
42
|
+
const client = new Client({ name: 'dsh-mcp-plus', version: '0.0.1' })
|
|
43
|
+
|
|
44
|
+
// 标记连接中(UI 圆点显示黄色"连接中"态)
|
|
45
|
+
setConnectionStatus(cfg.serverName, 'connecting')
|
|
46
|
+
// 连接失败时标记状态并抛错(connectAllServers 会接住,不让一台拖垮整批)
|
|
47
|
+
try {
|
|
48
|
+
await client.connect(transport)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
setConnectionStatus(cfg.serverName, 'disconnected')
|
|
51
|
+
throw err
|
|
52
|
+
}
|
|
53
|
+
setConnectionStatus(cfg.serverName, 'connected')
|
|
54
|
+
console.log(`[dsh-mcp-plus] (${cfg.serverName}) 已连接,开始发现工具...`)
|
|
55
|
+
|
|
56
|
+
const { tools } = await client.listTools()
|
|
57
|
+
|
|
58
|
+
// 收集本次注册的所有工具注销函数:清理时先注销工具再关连接,
|
|
59
|
+
// 否则旧工具残留在注册表里,重连时会 "already registered"
|
|
60
|
+
const toolDisposers: Array<() => void> = []
|
|
61
|
+
for (const tool of tools) {
|
|
62
|
+
const definition = {
|
|
63
|
+
// 公开名带服务器前缀 → 不同服务器的同名工具不会撞名
|
|
64
|
+
name: publicToolName(cfg.serverName, tool.name),
|
|
65
|
+
description: tool.description ?? '',
|
|
66
|
+
parameters: tool.inputSchema as Record<string, unknown>,
|
|
67
|
+
output: {
|
|
68
|
+
schema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
content: { type: 'array', items: {} },
|
|
72
|
+
},
|
|
73
|
+
required: ['content'],
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
render: (_args: unknown, value: unknown): ContentBlock[] => {
|
|
77
|
+
const result = value as { content: Array<{ type?: string; text?: string }> }
|
|
78
|
+
const text = (result.content ?? [])
|
|
79
|
+
.map(block => block.type === 'text' ? block.text ?? '' : `[${block.type}]`)
|
|
80
|
+
.join('\n')
|
|
81
|
+
return [{ type: 'text', text }]
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
// 闭包里记住原始名 rawName:AI 看到的是公开名,但转发给服务器必须用原始名
|
|
85
|
+
async execute(args: unknown) {
|
|
86
|
+
const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>
|
|
87
|
+
// 转发用原始名(服务器不认识 mcp__fs__ 前缀)
|
|
88
|
+
const result = await client.callTool({ name: tool.name, arguments: argsObj })
|
|
89
|
+
if (result.isError) {
|
|
90
|
+
throw new Error(`MCP 工具 ${tool.name} 执行失败`)
|
|
91
|
+
}
|
|
92
|
+
return { content: result.content as Array<{ type: string; text?: string }> }
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// 保存注销函数!清理时用它把工具从注册表移除
|
|
98
|
+
toolDisposers.push(ctx.tools.register(definition))
|
|
99
|
+
console.log(`[dsh-mcp-plus] (${cfg.serverName}) 已注册: ${definition.name}`)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.log(`[dsh-mcp-plus] (${cfg.serverName}) 注册失败 ${definition.name}: ${err instanceof Error ? err.message : String(err)}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(`[dsh-mcp-plus] (${cfg.serverName}) 完成:共 ${tools.length} 个工具`)
|
|
105
|
+
|
|
106
|
+
return async () => {
|
|
107
|
+
setConnectionStatus(cfg.serverName, 'disconnected')
|
|
108
|
+
// 先注销工具(防止残留)再关连接;注销失败不阻断关闭
|
|
109
|
+
for (const dispose of toolDisposers) {
|
|
110
|
+
try { dispose() } catch (err) {
|
|
111
|
+
console.log(`[dsh-mcp-plus] (${cfg.serverName}) 注销工具失败(继续): ${err instanceof Error ? err.message : String(err)}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await client.close()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 读配置文件 → 连接全部服务器 → 返回总清理函数。
|
|
120
|
+
* 初次加载和 UI 保存后的重连共用这一个函数。
|
|
121
|
+
* enabled === false 的服务器跳过(已配置但不连接)。
|
|
122
|
+
* 单台失败不影响其它服务器(容错),失败仅记日志,状态表标 disconnected。
|
|
123
|
+
*/
|
|
124
|
+
async function connectAllServers(ctx: Context): Promise<() => Promise<void>> {
|
|
125
|
+
// 开始新一轮连接前清空状态表(避免旧 serverName 残留)
|
|
126
|
+
clearConnectionStatus()
|
|
127
|
+
|
|
128
|
+
const allServers = await loadServers()
|
|
129
|
+
|
|
130
|
+
// serverName 唯一性检查:重复名是配置错误,响亮失败
|
|
131
|
+
const seen = new Set<string>()
|
|
132
|
+
for (const server of allServers) {
|
|
133
|
+
if (seen.has(server.serverName)) {
|
|
134
|
+
throw new Error(`[dsh-mcp-plus] serverName "${server.serverName}" 重复——每台服务器必须有唯一名字`)
|
|
135
|
+
}
|
|
136
|
+
seen.add(server.serverName)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 过滤禁用的服务器(enabled === false 跳过;缺省视为启用)
|
|
140
|
+
const active = allServers.filter(s => s.enabled !== false)
|
|
141
|
+
const skipped = allServers.length - active.length
|
|
142
|
+
if (skipped > 0) console.log(`[dsh-mcp-plus] 跳过 ${skipped} 台禁用服务器`)
|
|
143
|
+
|
|
144
|
+
// 禁用的服务器直接标 disabled(从未尝试连接)
|
|
145
|
+
for (const s of allServers) {
|
|
146
|
+
if (s.enabled === false) setConnectionStatus(s.serverName, 'disabled')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 逐个连接;单台失败不影响其它(容错)
|
|
150
|
+
const disposers: Array<() => Promise<void>> = []
|
|
151
|
+
for (const server of active) {
|
|
152
|
+
try {
|
|
153
|
+
disposers.push(await connectServer(ctx, server))
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.log(`[dsh-mcp-plus] (${server.serverName}) 连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
console.log(`[dsh-mcp-plus] 加载了 ${active.length} 台服务器(${disposers.length} 成功)`)
|
|
159
|
+
|
|
160
|
+
return async () => {
|
|
161
|
+
await Promise.all(disposers.map(dispose => dispose()))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function apply(ctx: Context): void {
|
|
166
|
+
// 当前连接集的清理函数。effect 与 UI 保存后的重连共享这一个引用:
|
|
167
|
+
// 不管谁重建连接,旧连接都会先被断开,插件卸载时断开的是最新一组。
|
|
168
|
+
let currentDispose: (() => Promise<void>) | undefined
|
|
169
|
+
|
|
170
|
+
/** 读配置 → 断开旧连接 → 连接新配置(初次加载与重连共用) */
|
|
171
|
+
const connectAll = async (): Promise<void> => {
|
|
172
|
+
// 先断开旧的(如果有);错误也要继续,别让一次断开失败卡死重连
|
|
173
|
+
if (currentDispose) {
|
|
174
|
+
try { await currentDispose() } catch (err) {
|
|
175
|
+
console.log('[dsh-mcp-plus] 断开旧连接失败(继续): ' + (err instanceof Error ? err.message : String(err)))
|
|
176
|
+
}
|
|
177
|
+
currentDispose = undefined
|
|
178
|
+
}
|
|
179
|
+
// connectAllServers 只负责连接,返回清理函数存到共享引用
|
|
180
|
+
currentDispose = await connectAllServers(ctx)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// HTTP 端点:UI 的读写通道。直接注册(不挂 effect)→ 重连不卸载,端点一直可达
|
|
184
|
+
// 保存成功后调 connectAll 完成重连
|
|
185
|
+
registerHttpRoutes(ctx, connectAll)
|
|
186
|
+
|
|
187
|
+
// 初次加载:挂在 effect 上,插件卸载时自动断开当前连接(生命周期管理)
|
|
188
|
+
// 注意:此处 effect 只管 MCP 连接,端点独立存活(注册时就没挂 effect)
|
|
189
|
+
ctx.effect(() => {
|
|
190
|
+
void connectAll()
|
|
191
|
+
// effect 的清理函数:断开「最新的」那组连接
|
|
192
|
+
return async () => {
|
|
193
|
+
if (currentDispose) await currentDispose()
|
|
194
|
+
currentDispose = undefined
|
|
195
|
+
}
|
|
196
|
+
}, 'dsh-mcp-plus.servers')
|
|
197
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// http.ts —— host 半的 HTTP 端点:让浏览器 UI 能读写服务器配置
|
|
2
|
+
//
|
|
3
|
+
// 注册到 DSH webServer 服务(dsh-pet 同款模式):
|
|
4
|
+
// GET /dsh-mcp-plus/servers → 返回当前服务器列表(JSON)
|
|
5
|
+
// PUT /dsh-mcp-plus/servers → 保存新列表 + 触发重连(响应里带回连接结果)
|
|
6
|
+
//
|
|
7
|
+
// 重连机制:PUT 保存后调用传入的 reload 回调——reload 由主插件提供,
|
|
8
|
+
// 它会断开所有现有连接(走 effect 清理)再按新配置重新连接。
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import { loadServers, saveServers, SERVERS_FILE } from './servers-store'
|
|
12
|
+
import { validateServers } from './config'
|
|
13
|
+
|
|
14
|
+
/** 我们的路由前缀(绝对路径,无尾斜杠) */
|
|
15
|
+
export const ROUTE_PREFIX = '/dsh-mcp-plus'
|
|
16
|
+
|
|
17
|
+
/** 模块级连接状态表(serverName → connecting / connected / disconnected / disabled) */
|
|
18
|
+
const connectionStatus = new Map<string, 'connecting' | 'connected' | 'disconnected' | 'disabled'>()
|
|
19
|
+
|
|
20
|
+
/** host 半在每次 connect/disconnect 时更新这张表,UI 通过 GET 拿到 */
|
|
21
|
+
export function setConnectionStatus(serverName: string, status: 'connecting' | 'connected' | 'disconnected' | 'disabled'): void {
|
|
22
|
+
connectionStatus.set(serverName, status)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 清空状态表(disconnect 所有连接时) */
|
|
26
|
+
export function clearConnectionStatus(): void {
|
|
27
|
+
connectionStatus.clear()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 读请求体(UTF-8 字符串) */
|
|
31
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const chunks: Buffer[] = []
|
|
34
|
+
req.on('data', chunk => chunks.push(chunk as Buffer))
|
|
35
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
36
|
+
req.on('error', reject)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 发送 JSON 响应 */
|
|
41
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
42
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
43
|
+
res.end(JSON.stringify(body))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 注册配置读写端点。直接注册到 webServer(不挂 effect → 重连不卸载,端点一直可达)。
|
|
48
|
+
* @param ctx - host 半插件上下文(需要 webServer 服务)
|
|
49
|
+
* @param reload - 保存成功后异步触发的重连函数(不等待,fire-and-forget)
|
|
50
|
+
*/
|
|
51
|
+
export function registerHttpRoutes(ctx: Context, reload: () => Promise<void>): void {
|
|
52
|
+
// inject = ['webServer'] 由主插件声明;这里直接从 ctx 取
|
|
53
|
+
const webServer = (ctx as unknown as { webServer?: {
|
|
54
|
+
register(route: { kind: string; path: string; handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }): () => void
|
|
55
|
+
} }).webServer
|
|
56
|
+
if (!webServer) {
|
|
57
|
+
console.log('[dsh-mcp-plus] webServer 服务不可用,HTTP 端点未注册(设置 UI 将无法保存)')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 直接注册到 webServer(不挂在 ctx.effect 里 → 重连不卸载,端点一直可达)
|
|
62
|
+
webServer.register({
|
|
63
|
+
kind: 'prefix',
|
|
64
|
+
path: ROUTE_PREFIX,
|
|
65
|
+
handler: async (req, res) => {
|
|
66
|
+
// prefix 路由收到的是完整路径(含 ROUTE_PREFIX),去掉查询串后做全路径匹配
|
|
67
|
+
const url = (req.url ?? '/').split('?')[0]
|
|
68
|
+
const method = req.method ?? 'GET'
|
|
69
|
+
const serversPath = ROUTE_PREFIX + '/servers'
|
|
70
|
+
|
|
71
|
+
// GET /dsh-mcp-plus/servers → 读配置 + 当前连接状态
|
|
72
|
+
if (url === serversPath && method === 'GET') {
|
|
73
|
+
try {
|
|
74
|
+
const servers = await loadServers()
|
|
75
|
+
// 给每个 serverName 附带连接状态:
|
|
76
|
+
// - enabled === false → 'disabled'(用户配置就是不连接)
|
|
77
|
+
// - connectionStatus 有记录 → 用记录值('connected' / 'disconnected')
|
|
78
|
+
// - 都没匹配 → 'disconnected'(刚启动/重连中,默认未连接)
|
|
79
|
+
const enriched = servers.map(s => {
|
|
80
|
+
const liveStatus = connectionStatus.get(s.serverName)
|
|
81
|
+
const status = s.enabled === false
|
|
82
|
+
? 'disabled'
|
|
83
|
+
: (liveStatus ?? 'connecting')
|
|
84
|
+
return { ...s, status }
|
|
85
|
+
})
|
|
86
|
+
sendJson(res, 200, { servers: enriched, file: SERVERS_FILE })
|
|
87
|
+
} catch (err) {
|
|
88
|
+
sendJson(res, 500, { error: '读取配置失败: ' + (err instanceof Error ? err.message : String(err)) })
|
|
89
|
+
}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// PUT /dsh-mcp-plus/servers → 只写盘,立即响应;重连异步进行,状态经圆点展示
|
|
94
|
+
if (url === serversPath && method === 'PUT') {
|
|
95
|
+
try {
|
|
96
|
+
const body = await readBody(req)
|
|
97
|
+
// 先校验再落盘:非法配置直接 400,不写脏数据
|
|
98
|
+
const servers = validateServers(JSON.parse(body))
|
|
99
|
+
await saveServers(servers)
|
|
100
|
+
// 异步重连:不阻塞响应。所有启用台先标 connecting,
|
|
101
|
+
// connectServer 成功→connected / 失败→disconnected,UI 轮询圆点看进展
|
|
102
|
+
void reload().catch(err => {
|
|
103
|
+
console.log('[dsh-mcp-plus] 重连失败: ' + (err instanceof Error ? err.message : String(err)))
|
|
104
|
+
})
|
|
105
|
+
sendJson(res, 200, { ok: true, count: servers.length })
|
|
106
|
+
} catch (err) {
|
|
107
|
+
sendJson(res, 400, { error: '保存失败: ' + (err instanceof Error ? err.message : String(err)) })
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
sendJson(res, 404, { error: `未知端点: ${method} ${url}` })
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// servers-store.ts —— 服务器配置文件的「读写」
|
|
2
|
+
// 回答:配置存在哪个文件?怎么读出来?怎么写回去?
|
|
3
|
+
// 路径沿用 dsh-pet 的惯例:~/.dsh/<插件名>/<文件名>
|
|
4
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
5
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
import { validateServers, type ServerConfig } from './config'
|
|
8
|
+
|
|
9
|
+
/** 配置文件绝对路径:~/.dsh/dsh-mcp-plus/servers.json */
|
|
10
|
+
export const SERVERS_FILE = dshHomePath('dsh-mcp-plus', 'servers.json')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 读配置文件,返回服务器列表。
|
|
14
|
+
* - 文件不存在 → 空列表(还没配置过任何服务器,正常情况)
|
|
15
|
+
* - 文件存在但非法 → 抛错(让上层知道配置坏了,别静默吞掉)
|
|
16
|
+
*/
|
|
17
|
+
export async function loadServers(): Promise<ServerConfig[]> {
|
|
18
|
+
let raw: string
|
|
19
|
+
try {
|
|
20
|
+
raw = await readFile(SERVERS_FILE, 'utf8')
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
23
|
+
return [] // 文件不存在 = 还没配置过
|
|
24
|
+
}
|
|
25
|
+
throw err
|
|
26
|
+
}
|
|
27
|
+
// 读出的 JSON 文本 → 解析成对象 → 校验(校验失败会抛错)
|
|
28
|
+
return validateServers(JSON.parse(raw))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 写配置文件(全量覆盖)。
|
|
33
|
+
* 自动创建目录。写入前剥离运行时字段(status 等)——它们不该被持久化,
|
|
34
|
+
* 否则下次读取会被当作配置的一部分(servers.json 里已经出现过这个污染)。
|
|
35
|
+
*/
|
|
36
|
+
export async function saveServers(servers: ServerConfig[]): Promise<void> {
|
|
37
|
+
await mkdir(dirname(SERVERS_FILE), { recursive: true })
|
|
38
|
+
// 只保留配置字段,丢弃 status 等运行时注入
|
|
39
|
+
const clean = servers.map(({ serverName, transport, command, args, env, cwd, enabled }) => ({
|
|
40
|
+
transport, serverName, command, args, env, cwd,
|
|
41
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
42
|
+
}))
|
|
43
|
+
await writeFile(SERVERS_FILE, JSON.stringify({ servers: clean }, null, 2) + '\n', 'utf8')
|
|
44
|
+
}
|