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