dsh-vscode-mode 0.1.10 → 0.1.11
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 +12 -0
- package/lib/client.js +536 -53
- package/lib/client.js.map +1 -1
- package/lib/index.js +1003 -94
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +44 -8
- package/src/client/index.ts +11 -2
- package/src/client/state/records.ts +5 -2
- package/src/client/state/regions.ts +26 -20
- package/src/client/styles/mcp.css +87 -0
- package/src/client/ui/DiffBox.ts +1 -0
- package/src/client/ui/McpSettings.ts +233 -0
- package/src/index.ts +4 -2
- package/src/mcp.ts +123 -0
- package/src/mcpIsolation.ts +160 -0
- package/src/mcpProject.ts +258 -0
- package/src/model.ts +66 -34
- package/src/revert.ts +10 -3
- package/src/rpc.ts +44 -0
- package/src/shared/diff.ts +201 -0
- package/src/shared/mcp.ts +53 -0
- package/src/shared/rpc.ts +21 -0
- package/src/shared/types.ts +15 -0
- package/src/store.ts +29 -31
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — MCP 运行时管理适配层。
|
|
3
|
+
* 只操作已装配的 @deepseek-ai/dsh-mcp-client loader entry,不在浏览器侧连接 MCP。
|
|
4
|
+
* 作者 ddj 2026年08月22号
|
|
5
|
+
*/
|
|
6
|
+
import type { MpcConfig, MpcServer, MpcTool } from './shared/mcp.js'
|
|
7
|
+
import type { Ctx } from './store.js'
|
|
8
|
+
|
|
9
|
+
const MCP_PACKAGE = '@deepseek-ai/dsh-mcp-client'
|
|
10
|
+
/** 项目级 MCP 的 loader entry id 固定前缀(区分 profile 全局 MCP;注意 loader id 不能用 ':' 分隔,故用 '.')。 */
|
|
11
|
+
export const PROJECT_ENTRY_PREFIX = 'vsm-mcp.'
|
|
12
|
+
const LEGACY_PROJECT_PREFIX = 'vsm-mcp:'
|
|
13
|
+
|
|
14
|
+
/** 判断新旧格式项目 entry id,避免旧残留被显示为全局 MCP。 */
|
|
15
|
+
export function isProjectEntryId(id: string): boolean {
|
|
16
|
+
return id.startsWith(PROJECT_ENTRY_PREFIX) || id.startsWith(LEGACY_PROJECT_PREFIX)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 列出当前 loader 中的 MCP entry。 */
|
|
20
|
+
function entriesOf(ctx: Ctx): any[] {
|
|
21
|
+
const loader = ctx.get('loader')
|
|
22
|
+
if (!loader || typeof loader.entries !== 'function') return []
|
|
23
|
+
return [...loader.entries()].filter((entry) => entry?.options?.name === MCP_PACKAGE)
|
|
24
|
+
}
|
|
25
|
+
export { entriesOf }
|
|
26
|
+
|
|
27
|
+
/** 脱敏配置并保留可编辑字段的存在性。 */
|
|
28
|
+
export function publicConfig(config: Record<string, unknown>): MpcConfig {
|
|
29
|
+
const out = { ...config } as Record<string, unknown>
|
|
30
|
+
if (out.env && typeof out.env === 'object') out.env = Object.fromEntries(Object.keys(out.env as object).map((key) => [key, '••••••']))
|
|
31
|
+
if (out.headers && typeof out.headers === 'object') out.headers = Object.fromEntries(Object.keys(out.headers as object).map((key) => [key, '••••••']))
|
|
32
|
+
return out as unknown as MpcConfig
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 从工具注册表读取服务器命名空间下的工具摘要。 */
|
|
36
|
+
export function toolsOf(ctx: Ctx, serverName: string): MpcTool[] {
|
|
37
|
+
const tools = ctx.get('tools')
|
|
38
|
+
const view = tools?.view?.(void 0)
|
|
39
|
+
const visible = view?.visible
|
|
40
|
+
if (!(visible instanceof Map)) return []
|
|
41
|
+
const prefix = `mcp__${serverName}__`
|
|
42
|
+
return [...visible.entries()].filter(([name]) => String(name).startsWith(prefix)).map(([name, definition]) => ({
|
|
43
|
+
name: String(name).slice(prefix.length),
|
|
44
|
+
description: typeof definition?.description === 'string' ? definition.description : undefined,
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 将 loader entry 映射为设置页摘要。 */
|
|
49
|
+
export function serverOf(ctx: Ctx, entry: any): MpcServer {
|
|
50
|
+
const config = (entry.options?.config ?? {}) as Record<string, unknown>
|
|
51
|
+
const serverName = typeof config.serverName === 'string' ? config.serverName : entry.options?.id ?? 'unknown'
|
|
52
|
+
const tools = toolsOf(ctx, serverName)
|
|
53
|
+
const state = entry.fiber?.state
|
|
54
|
+
return {
|
|
55
|
+
id: String(entry.id ?? entry.options?.id ?? serverName),
|
|
56
|
+
serverName,
|
|
57
|
+
enabled: entry.disabled !== true,
|
|
58
|
+
transport: config.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
59
|
+
config: publicConfig(config),
|
|
60
|
+
status: state === 2 ? 'connected' : state === 1 ? 'connecting' : 'error',
|
|
61
|
+
toolCount: tools.length,
|
|
62
|
+
tools,
|
|
63
|
+
error: state === 3 ? 'MCP 插件未正常运行' : undefined,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 校验 MCP 配置。 */
|
|
68
|
+
export function validateConfig(config: MpcConfig): void {
|
|
69
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(config.serverName)) throw new Error('serverName 只能包含字母、数字、下划线和连字符(最多 32 位)')
|
|
70
|
+
if (config.transport === 'stdio' && !config.command?.trim()) throw new Error('stdio MCP 必须填写 command')
|
|
71
|
+
if (config.transport === 'streamable-http' && !/^https?:\/\//.test(config.url ?? '')) throw new Error('HTTP MCP 必须填写 http(s) URL')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 列出全局(profile)MCP 服务,过滤掉新旧格式的项目级条目。 */
|
|
75
|
+
export function listMcp(ctx: Ctx): { servers: MpcServer[] } {
|
|
76
|
+
return { servers: entriesOf(ctx).filter((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))).map((entry) => serverOf(ctx, entry)) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 保存 MCP 服务配置并触发 loader 热更新。 */
|
|
80
|
+
export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
|
|
81
|
+
validateConfig(config)
|
|
82
|
+
const loader = ctx.get('loader')
|
|
83
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
84
|
+
const existing = entriesOf(ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
|
|
85
|
+
if (existing) {
|
|
86
|
+
await loader.update(existing.id, { config: { ...config } })
|
|
87
|
+
return serverOf(ctx, loader.resolve(existing.id))
|
|
88
|
+
}
|
|
89
|
+
const id = await loader.create({ name: MCP_PACKAGE, config: { ...config } })
|
|
90
|
+
return serverOf(ctx, loader.resolve(id))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 删除 MCP 服务。 */
|
|
94
|
+
export async function removeMcp(ctx: Ctx, id: string): Promise<void> {
|
|
95
|
+
const loader = ctx.get('loader')
|
|
96
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
97
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
|
|
98
|
+
if (!entry) throw new Error('MCP 服务不存在')
|
|
99
|
+
await loader.remove(entry.id)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 切换 MCP 服务启用状态。 */
|
|
103
|
+
export async function toggleMcp(ctx: Ctx, id: string, enabled: boolean): Promise<MpcServer> {
|
|
104
|
+
const loader = ctx.get('loader')
|
|
105
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
106
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
|
|
107
|
+
if (!entry) throw new Error('MCP 服务不存在')
|
|
108
|
+
await loader.update(entry.id, { disabled: !enabled })
|
|
109
|
+
return serverOf(ctx, loader.resolve(entry.id))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 重新加载 MCP 服务。 */
|
|
113
|
+
export async function refreshMcp(ctx: Ctx, id: string): Promise<MpcServer> {
|
|
114
|
+
const loader = ctx.get('loader')
|
|
115
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
116
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
|
|
117
|
+
if (!entry) throw new Error('MCP 服务不存在')
|
|
118
|
+
await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
|
|
119
|
+
return serverOf(ctx, loader.resolve(entry.id))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type { MpcConfig, MpcServer }
|
|
123
|
+
export const mcpPackageName = MCP_PACKAGE
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 项目 MCP 的 agent 级隔离。
|
|
3
|
+
* 项目 MCP 连接仍由 Host 维护,但每个 agent 只继承当前 workspace 的项目工具;
|
|
4
|
+
* tools.restrict() 负责模型可见性,tools.guard() 负责执行层兜底拒绝。
|
|
5
|
+
* 作者 ddj 2026年08月22号
|
|
6
|
+
*/
|
|
7
|
+
import { PROJECT_ENTRY_PREFIX, entriesOf } from './mcp.js'
|
|
8
|
+
import { hashWorkspace } from './mcpProject.js'
|
|
9
|
+
import type { Ctx } from './store.js'
|
|
10
|
+
|
|
11
|
+
const LEGACY_PREFIX = 'vsm-mcp:'
|
|
12
|
+
const UNKNOWN_PROJECT = '__unknown_project__'
|
|
13
|
+
|
|
14
|
+
type ToolProjects = Map<string, string>
|
|
15
|
+
type AgentState = { dispose?: () => void; denyKey: string }
|
|
16
|
+
|
|
17
|
+
/** 统一 workspace 路径,供 cwd 父路径匹配。 */
|
|
18
|
+
export function normalizePath(path: string): string {
|
|
19
|
+
const value = String(path ?? '').replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
20
|
+
return value.replace(/\/$/, '').toLowerCase()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 在已注册 workspace 中匹配 cwd,优先最长父路径。 */
|
|
24
|
+
export function matchWorkspace(cwd: string | undefined, paths: string[]): string | undefined {
|
|
25
|
+
if (!cwd) return undefined
|
|
26
|
+
const target = normalizePath(cwd)
|
|
27
|
+
const matches = paths.filter((path) => {
|
|
28
|
+
const root = normalizePath(path)
|
|
29
|
+
return target === root || target.startsWith(root + '/')
|
|
30
|
+
})
|
|
31
|
+
matches.sort((a, b) => normalizePath(b).length - normalizePath(a).length)
|
|
32
|
+
return matches[0]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 根据 entry id 判断是否为新旧格式项目 MCP。 */
|
|
36
|
+
export function isProjectEntry(id: string): boolean {
|
|
37
|
+
return id.startsWith(PROJECT_ENTRY_PREFIX) || id.startsWith(LEGACY_PREFIX)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 从项目 entry id 解析 workspace hash。 */
|
|
41
|
+
function entryHash(id: string): string | undefined {
|
|
42
|
+
if (id.startsWith(PROJECT_ENTRY_PREFIX)) return id.slice(PROJECT_ENTRY_PREFIX.length).split('.')[0]
|
|
43
|
+
if (id.startsWith(LEGACY_PREFIX)) return id.slice(LEGACY_PREFIX.length).split(':')[0]
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 返回一个 agent 不应继承的项目工具名。 */
|
|
48
|
+
export function denyTools(toolProjects: ToolProjects, currentPath: string | undefined): string[] {
|
|
49
|
+
return [...toolProjects.entries()].filter(([, owner]) => owner !== currentPath).map(([name]) => name).sort()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 从 loader entry 和工具注册表建立工具到项目路径的映射。 */
|
|
53
|
+
function projectTools(ctx: Ctx): ToolProjects {
|
|
54
|
+
const result: ToolProjects = new Map()
|
|
55
|
+
const registry = ctx.get('workspaceRegistry')
|
|
56
|
+
const workspaces = registry?.list?.() ?? []
|
|
57
|
+
const byHash: Map<string, string> = new Map(workspaces.map((ws: { path: string }) => [hashWorkspace(ws.path), ws.path] as [string, string]))
|
|
58
|
+
const visible = ctx.get('tools')?.view?.(void 0)?.visible
|
|
59
|
+
if (!(visible instanceof Map)) return result
|
|
60
|
+
for (const entry of entriesOf(ctx)) {
|
|
61
|
+
const id = String(entry.id ?? entry.options?.id ?? '')
|
|
62
|
+
if (!isProjectEntry(id)) continue
|
|
63
|
+
const hash = entryHash(id)
|
|
64
|
+
const owner = hash ? byHash.get(hash) ?? UNKNOWN_PROJECT : UNKNOWN_PROJECT
|
|
65
|
+
const serverName = entry.options?.config?.serverName
|
|
66
|
+
if (typeof serverName !== 'string') continue
|
|
67
|
+
const prefix = `mcp__${serverName}__`
|
|
68
|
+
for (const name of visible.keys()) if (String(name).startsWith(prefix)) result.set(String(name), owner)
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 取得 agent 当前 cwd 所属 workspace。 */
|
|
74
|
+
function agentWorkspace(ctx: Ctx, agent: any): string | undefined {
|
|
75
|
+
const cwd = agent?.session?.header?.cwd
|
|
76
|
+
const paths = (ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string }) => ws.path)
|
|
77
|
+
return matchWorkspace(cwd, paths)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 同步一个 agent 的项目工具 deny 列表。 */
|
|
81
|
+
function syncAgent(ctx: Ctx, state: AgentState, agent: any, tools: ToolProjects): void {
|
|
82
|
+
const current = agentWorkspace(ctx, agent)
|
|
83
|
+
const denied = denyTools(tools, current)
|
|
84
|
+
const nextKey = denied.join('\n')
|
|
85
|
+
if (state.denyKey === nextKey) return
|
|
86
|
+
state.dispose?.()
|
|
87
|
+
state.dispose = undefined
|
|
88
|
+
if (denied.length > 0) {
|
|
89
|
+
try {
|
|
90
|
+
state.dispose = agent.ctx.tools.restrict({ deny: denied })
|
|
91
|
+
} catch (error) {
|
|
92
|
+
ctx.logger?.warn?.('[dsh-vscode-mode] MCP agent restriction failed: ' + String(error))
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
state.denyKey = nextKey
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 为当前 Host 安装项目 MCP 的 agent restriction 与执行 guard。 */
|
|
100
|
+
export function installIsolation(ctx: Ctx): void {
|
|
101
|
+
const tools = ctx.get('tools')
|
|
102
|
+
const agents = ctx.get('agents')
|
|
103
|
+
if (!tools || !agents) return
|
|
104
|
+
const states = new Map<any, AgentState>()
|
|
105
|
+
let toolProjects = projectTools(ctx)
|
|
106
|
+
let scheduled = false
|
|
107
|
+
let syncing = false
|
|
108
|
+
|
|
109
|
+
const syncAll = () => {
|
|
110
|
+
if (syncing) return
|
|
111
|
+
syncing = true
|
|
112
|
+
try {
|
|
113
|
+
toolProjects = projectTools(ctx)
|
|
114
|
+
for (const agent of agents.list?.() ?? []) {
|
|
115
|
+
const state = states.get(agent) ?? { denyKey: '' }
|
|
116
|
+
states.set(agent, state)
|
|
117
|
+
syncAgent(ctx, state, agent, toolProjects)
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
syncing = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const scheduleSync = () => {
|
|
124
|
+
if (scheduled) return
|
|
125
|
+
scheduled = true
|
|
126
|
+
queueMicrotask(() => {
|
|
127
|
+
scheduled = false
|
|
128
|
+
syncAll()
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
const onCreated = ctx.on('agent/created', ({ agent }: { agent: any }) => {
|
|
132
|
+
const state = { denyKey: '' }
|
|
133
|
+
states.set(agent, state)
|
|
134
|
+
syncAgent(ctx, state, agent, toolProjects)
|
|
135
|
+
})
|
|
136
|
+
const onDisposed = ctx.on('agent/disposed', ({ agent }: { agent: any }) => {
|
|
137
|
+
const state = states.get(agent)
|
|
138
|
+
state?.dispose?.()
|
|
139
|
+
states.delete(agent)
|
|
140
|
+
})
|
|
141
|
+
const onToolsChange = ctx.on('tools/change', scheduleSync)
|
|
142
|
+
const stopGuard = tools.guard((exec: any) => {
|
|
143
|
+
const owner = toolProjects.get(String(exec?.name ?? ''))
|
|
144
|
+
if (!owner) return undefined
|
|
145
|
+
const current = agentWorkspace(ctx, exec.agent)
|
|
146
|
+
if (owner === current) return undefined
|
|
147
|
+
return current === undefined
|
|
148
|
+
? '项目 MCP 需要在已注册工作区的对话中使用'
|
|
149
|
+
: '已拒绝:当前对话工作区不能使用其他项目的 MCP'
|
|
150
|
+
})
|
|
151
|
+
syncAll()
|
|
152
|
+
ctx.effect(() => () => {
|
|
153
|
+
onCreated?.()
|
|
154
|
+
onDisposed?.()
|
|
155
|
+
onToolsChange?.()
|
|
156
|
+
stopGuard?.()
|
|
157
|
+
for (const state of states.values()) state.dispose?.()
|
|
158
|
+
states.clear()
|
|
159
|
+
}, 'vscode-mode:mcp-isolation')
|
|
160
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 项目级 MCP 管理。
|
|
3
|
+
* 配置以项目根目录 .mcp.json(mcpServers 格式,对齐 Claude Code/Cursor)存储;
|
|
4
|
+
* 工具经 loader 动态激活(entry id 带 PROJECT_ENTRY_PREFIX,全局生效)。
|
|
5
|
+
* 重启后由本项目从各 .mcp.json 恢复激活;.mcp.json 为持久真相,reconcile 以文件为准。
|
|
6
|
+
* 作者 ddj 2026年08月22号
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { PROJECT_ENTRY_PREFIX, entriesOf, serverOf, validateConfig } from './mcp.js'
|
|
10
|
+
import type { MpcConfig, MpcProject, MpcServer } from './shared/mcp.js'
|
|
11
|
+
import type { Ctx } from './store.js'
|
|
12
|
+
|
|
13
|
+
const FILE_NAME = '.mcp.json'
|
|
14
|
+
|
|
15
|
+
/** 校验项目路径属于 DSH 已注册 workspace,避免 RPC 写入任意目录。 */
|
|
16
|
+
function requireWorkspace(ctx: Ctx, workspacePath: string): { path: string; title?: string } {
|
|
17
|
+
const workspace = (ctx.get('workspaceRegistry')?.list?.() ?? []).find((item: { path: string }) => item.path === workspacePath)
|
|
18
|
+
if (!workspace) throw new Error('项目未注册为 DSH workspace,不能管理项目 MCP')
|
|
19
|
+
return workspace
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 用户显式 GUI 管理操作的写策略:danger-full-access(写入路径固定为 workspacePath/.mcp.json)。 */
|
|
23
|
+
function fullPolicy(ctx: Ctx): unknown {
|
|
24
|
+
const svc = ctx.get('sandboxPolicy')
|
|
25
|
+
if (!svc || typeof svc.resolve !== 'function') return undefined
|
|
26
|
+
return svc.resolve({ mode: 'danger-full-access' })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** workspace 路径 → 确定性短哈希(entry id 归属段,重启后稳定可解码)。 */
|
|
30
|
+
export function hashWorkspace(path: string): string {
|
|
31
|
+
return createHash('sha1').update(path).digest('hex').slice(0, 10)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 项目 MCP 的 loader entry id:vsm-mcp.<pathHash>.<serverName>(loader id 不能用冒号)。 */
|
|
35
|
+
export function projectEntryId(workspacePath: string, serverName: string): string {
|
|
36
|
+
return PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.' + serverName
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 读取项目 .mcp.json 顶层对象(缺失 → 空;非法 → 抛错带文案)。 */
|
|
40
|
+
async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<string, unknown>> {
|
|
41
|
+
const fs = ctx.get('fs')
|
|
42
|
+
if (!fs) throw new Error('缺少 fs 服务')
|
|
43
|
+
const target = await fs.resolve(FILE_NAME, { cwd: workspacePath })
|
|
44
|
+
let text: string
|
|
45
|
+
try {
|
|
46
|
+
text = await fs.readText(target)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
let data: unknown
|
|
51
|
+
try {
|
|
52
|
+
data = JSON.parse(text)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error('.mcp.json 解析失败:' + String(error))
|
|
55
|
+
}
|
|
56
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('.mcp.json 顶层必须是对象')
|
|
57
|
+
return data as Record<string, unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 读取并校验 .mcp.json 的 mcpServers 映射(纯函数,可单测)。 */
|
|
61
|
+
export function serversOf(data: Record<string, unknown>): Record<string, Record<string, unknown>> {
|
|
62
|
+
const raw = data.mcpServers
|
|
63
|
+
if (raw === undefined) return {}
|
|
64
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('.mcp.json 的 mcpServers 必须是对象')
|
|
65
|
+
const out: Record<string, Record<string, unknown>> = {}
|
|
66
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
67
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
|
|
68
|
+
out[name] = value as Record<string, unknown>
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null,纯函数可单测)。 */
|
|
74
|
+
export function configFromDef(def: Record<string, unknown> | undefined, serverName: string): MpcConfig | null {
|
|
75
|
+
if (!def) return null
|
|
76
|
+
const transport = typeof def.url === 'string' ? 'streamable-http' : 'stdio'
|
|
77
|
+
const config: MpcConfig = { serverName, transport }
|
|
78
|
+
if (transport === 'stdio') {
|
|
79
|
+
config.command = typeof def.command === 'string' ? def.command : ''
|
|
80
|
+
config.args = Array.isArray(def.args) ? def.args.map(String) : []
|
|
81
|
+
if (typeof def.cwd === 'string') config.cwd = def.cwd
|
|
82
|
+
if (def.env && typeof def.env === 'object') config.env = Object.fromEntries(Object.entries(def.env as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
|
|
83
|
+
} else {
|
|
84
|
+
config.url = def.url as string
|
|
85
|
+
if (def.headers && typeof def.headers === 'object') config.headers = Object.fromEntries(Object.entries(def.headers as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
|
|
86
|
+
}
|
|
87
|
+
if (typeof def.toolCallTimeoutMs === 'number') config.toolCallTimeoutMs = def.toolCallTimeoutMs
|
|
88
|
+
return config
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 写回 .mcp.json(保留顶层未知字段与其余 mcpServers,仅改目标 serverName)。 */
|
|
92
|
+
async function writeProjectJson(ctx: Ctx, workspacePath: string, data: Record<string, unknown>, mcpServers: Record<string, Record<string, unknown>>): Promise<void> {
|
|
93
|
+
const fs = ctx.get('fs')
|
|
94
|
+
if (!fs) throw new Error('缺少 fs 服务')
|
|
95
|
+
const target = await fs.resolve(FILE_NAME, { cwd: workspacePath })
|
|
96
|
+
const next = { ...data, mcpServers }
|
|
97
|
+
await fs.writeText(target, JSON.stringify(next, null, 2) + '\n', void 0, void 0, fullPolicy(ctx))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 查重:目标 serverName 是否已被其他 loader entry(全局或他项目)占用。 */
|
|
101
|
+
function findConflict(ctx: Ctx, workspacePath: string, serverName: string): string | null {
|
|
102
|
+
const ownId = projectEntryId(workspacePath, serverName)
|
|
103
|
+
for (const entry of entriesOf(ctx)) {
|
|
104
|
+
if (String(entry.id ?? entry.options?.id ?? '') === ownId) continue
|
|
105
|
+
if (entry.options?.config?.serverName === serverName) {
|
|
106
|
+
return 'serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称'
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 激活一个项目 MCP:校验 + 查重 + loader.create。 */
|
|
113
|
+
export async function activateProjectMcp(ctx: Ctx, workspacePath: string, config: MpcConfig): Promise<MpcServer> {
|
|
114
|
+
validateConfig(config)
|
|
115
|
+
const conflict = findConflict(ctx, workspacePath, config.serverName)
|
|
116
|
+
if (conflict) throw new Error(conflict)
|
|
117
|
+
const loader = ctx.get('loader')
|
|
118
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
119
|
+
const id = projectEntryId(workspacePath, config.serverName)
|
|
120
|
+
const existing = entriesOf(ctx).find((entry) => String(entry.id ?? entry.options?.id ?? '') === id)
|
|
121
|
+
if (existing) {
|
|
122
|
+
await loader.update(existing.id, { config: { ...config } })
|
|
123
|
+
return serverOf(ctx, loader.resolve(existing.id))
|
|
124
|
+
}
|
|
125
|
+
const created = await loader.create({ id, name: '@deepseek-ai/dsh-mcp-client', config: { ...config } })
|
|
126
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === id)
|
|
127
|
+
if (!entry) throw new Error('MCP 已创建但未能找到 entry(id=' + id + ')')
|
|
128
|
+
return serverOf(ctx, entry)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 停用一个项目 MCP(按 entry id)。 */
|
|
132
|
+
export async function deactivateMcp(ctx: Ctx, entryId: string): Promise<void> {
|
|
133
|
+
const loader = ctx.get('loader')
|
|
134
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
135
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === entryId)
|
|
136
|
+
if (!entry) return
|
|
137
|
+
await loader.remove(entry.id)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 以 .mcp.json 为真相 reconcile 该项目的激活状态(幂等)。 */
|
|
141
|
+
export async function reconcileProject(ctx: Ctx, workspacePath: string): Promise<void> {
|
|
142
|
+
const data = await readProjectJson(ctx, workspacePath)
|
|
143
|
+
const fileServers = new Set(Object.keys(serversOf(data)))
|
|
144
|
+
const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.'
|
|
145
|
+
for (const entry of entriesOf(ctx)) {
|
|
146
|
+
const id = String(entry.id ?? entry.options?.id ?? '')
|
|
147
|
+
if (!id.startsWith(prefix)) continue
|
|
148
|
+
const serverName = id.slice(prefix.length)
|
|
149
|
+
if (fileServers.has(serverName)) continue
|
|
150
|
+
await deactivateMcp(ctx, id)
|
|
151
|
+
}
|
|
152
|
+
for (const serverName of fileServers) {
|
|
153
|
+
const config = await configOfServer(ctx, workspacePath, serverName)
|
|
154
|
+
if (config === null) continue
|
|
155
|
+
const id = projectEntryId(workspacePath, serverName)
|
|
156
|
+
if (entriesOf(ctx).some((entry) => String(entry.id ?? entry.options?.id ?? '') === id)) continue
|
|
157
|
+
await activateProjectMcp(ctx, workspacePath, { ...config, serverName })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null)。 */
|
|
162
|
+
async function configOfServer(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcConfig | null> {
|
|
163
|
+
const data = await readProjectJson(ctx, workspacePath)
|
|
164
|
+
return configFromDef(serversOf(data)[serverName], serverName)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 单个项目的完整视图(fileData + 激活状态合并)。 */
|
|
168
|
+
async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promise<MpcProject> {
|
|
169
|
+
const fs = ctx.get('fs')
|
|
170
|
+
if (!fs) return { workspacePath, title, servers: [], source: 'project' }
|
|
171
|
+
try {
|
|
172
|
+
const target = await fs.resolve(workspacePath)
|
|
173
|
+
const info = await fs.stat(target)
|
|
174
|
+
if (!info || info.type !== 'directory') return { workspacePath, title, servers: [], source: 'project', missingDir: true }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return { workspacePath, title, servers: [], source: 'project', missingDir: true }
|
|
177
|
+
}
|
|
178
|
+
await reconcileProject(ctx, workspacePath)
|
|
179
|
+
let servers: MpcServer[] = []
|
|
180
|
+
let fileError: string | undefined
|
|
181
|
+
try {
|
|
182
|
+
const data = await readProjectJson(ctx, workspacePath)
|
|
183
|
+
const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.'
|
|
184
|
+
servers = entriesOf(ctx).filter((entry) => String(entry.id ?? entry.options?.id ?? '').startsWith(prefix)).map((entry) => serverOf(ctx, entry))
|
|
185
|
+
servers.sort((a, b) => (a.serverName < b.serverName ? -1 : 1))
|
|
186
|
+
} catch (error) {
|
|
187
|
+
fileError = String(error)
|
|
188
|
+
}
|
|
189
|
+
return { workspacePath, title, servers, source: 'project', fileError }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 列出全部项目及其项目级 MCP。 */
|
|
193
|
+
export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }> {
|
|
194
|
+
const registry = ctx.get('workspaceRegistry')
|
|
195
|
+
const workspaces = registry?.list?.() ?? []
|
|
196
|
+
const projects: MpcProject[] = []
|
|
197
|
+
for (const ws of workspaces) projects.push(await projectOf(ctx, ws.path, ws.title ?? ''))
|
|
198
|
+
return { projects }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 保存(新增/更新)一个项目 MCP:写 .mcp.json → 激活。 */
|
|
202
|
+
export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
|
|
203
|
+
const workspace = requireWorkspace(ctx, workspacePath)
|
|
204
|
+
validateConfig({ ...config, serverName })
|
|
205
|
+
const conflict = findConflict(ctx, workspacePath, serverName)
|
|
206
|
+
if (conflict) throw new Error(conflict)
|
|
207
|
+
const data = await readProjectJson(ctx, workspacePath)
|
|
208
|
+
const servers = serversOf(data)
|
|
209
|
+
const next = { ...config } as unknown as Record<string, unknown>
|
|
210
|
+
delete next.serverName
|
|
211
|
+
delete next.transport
|
|
212
|
+
delete next.disabled // disabled 由 projectToggle 管理,保存恒为启用
|
|
213
|
+
servers[serverName] = next
|
|
214
|
+
await writeProjectJson(ctx, workspacePath, data, servers)
|
|
215
|
+
await activateProjectMcp(ctx, workspacePath, { ...config, serverName })
|
|
216
|
+
return projectOf(ctx, workspacePath, workspace.title ?? '')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 删除一个项目 MCP:停用 → 从 .mcp.json 移除。 */
|
|
220
|
+
export async function projectRemove(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
|
|
221
|
+
const workspace = requireWorkspace(ctx, workspacePath)
|
|
222
|
+
await deactivateMcp(ctx, projectEntryId(workspacePath, serverName))
|
|
223
|
+
const data = await readProjectJson(ctx, workspacePath)
|
|
224
|
+
const servers = serversOf(data)
|
|
225
|
+
delete servers[serverName]
|
|
226
|
+
await writeProjectJson(ctx, workspacePath, data, servers)
|
|
227
|
+
return projectOf(ctx, workspacePath, workspace.title ?? '')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 切换项目 MCP 启用状态(持久化到 .mcp.json + loader.update)。 */
|
|
231
|
+
export async function projectToggle(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
|
|
232
|
+
const workspace = requireWorkspace(ctx, workspacePath)
|
|
233
|
+
const data = await readProjectJson(ctx, workspacePath)
|
|
234
|
+
const servers = serversOf(data)
|
|
235
|
+
const def = servers[serverName]
|
|
236
|
+
if (!def) throw new Error('项目中不存在该 MCP')
|
|
237
|
+
if (enabled) delete def.disabled
|
|
238
|
+
else def.disabled = true
|
|
239
|
+
await writeProjectJson(ctx, workspacePath, data, servers)
|
|
240
|
+
const loader = ctx.get('loader')
|
|
241
|
+
if (loader) {
|
|
242
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === projectEntryId(workspacePath, serverName))
|
|
243
|
+
if (entry) await loader.update(entry.id, { disabled: !enabled })
|
|
244
|
+
}
|
|
245
|
+
return projectOf(ctx, workspacePath, workspace.title ?? '')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 刷新(重连)项目 MCP。 */
|
|
249
|
+
export async function projectRefresh(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
|
|
250
|
+
const workspace = requireWorkspace(ctx, workspacePath)
|
|
251
|
+
const loader = ctx.get('loader')
|
|
252
|
+
if (!loader) throw new Error('缺少 loader 服务')
|
|
253
|
+
const id = projectEntryId(workspacePath, serverName)
|
|
254
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === id)
|
|
255
|
+
if (!entry) throw new Error('该项目 MCP 未激活')
|
|
256
|
+
await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
|
|
257
|
+
return projectOf(ctx, workspacePath, workspace.title ?? '')
|
|
258
|
+
}
|