dsh-vscode-mode 0.1.7 → 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 +725 -89
- 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/monaco/diffRender.ts +17 -2
- package/src/client/monaco/loader.ts +56 -9
- package/src/client/state/records.ts +5 -2
- package/src/client/state/regions.ts +26 -20
- package/src/client/styles/editor.css +16 -2
- package/src/client/styles/mcp.css +87 -0
- package/src/client/ui/DiffBox.ts +1 -0
- package/src/client/ui/EditorView.ts +70 -8
- 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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — VSCodeMode 设置区内的 MCP 管理。
|
|
4
|
+
* 子 Tab:我的 MCP(profile 全局)/ 项目 MCP(各项目 .mcp.json)/ MCP 市场(占位)。
|
|
5
|
+
* 作者 ddj 2026年08月22号
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react'
|
|
8
|
+
import { rpc } from '../rpc.js'
|
|
9
|
+
import type { MpcProject, MpcServer } from '../../shared/mcp.js'
|
|
10
|
+
import '../styles/mcp.css'
|
|
11
|
+
|
|
12
|
+
const EMPTY = { serverName: '', transport: 'stdio', command: '', args: '', cwd: '', url: '', headers: '' }
|
|
13
|
+
|
|
14
|
+
/** 将表单草稿转换为 Host MCP 配置。 */
|
|
15
|
+
function configOf(draft) {
|
|
16
|
+
const config = { serverName: draft.serverName.trim(), transport: draft.transport }
|
|
17
|
+
if (draft.transport === 'stdio') {
|
|
18
|
+
config.command = draft.command.trim()
|
|
19
|
+
config.args = draft.args.split(/\r?\n|\s+/).filter(Boolean)
|
|
20
|
+
if (draft.cwd.trim()) config.cwd = draft.cwd.trim()
|
|
21
|
+
} else {
|
|
22
|
+
config.url = draft.url.trim()
|
|
23
|
+
config.headers = parsePairs(draft.headers)
|
|
24
|
+
}
|
|
25
|
+
return config
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 解析每行 key=value 的表单字段。 */
|
|
29
|
+
function parsePairs(text) {
|
|
30
|
+
return Object.fromEntries(text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
31
|
+
const idx = line.indexOf('=')
|
|
32
|
+
return idx > 0 ? [line.slice(0, idx).trim(), line.slice(idx + 1).trim()] : [line, '']
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 服务状态文案。 */
|
|
37
|
+
function statusOf(server) {
|
|
38
|
+
return server.status === 'connected' ? '在线' : server.status === 'connecting' ? '连接中' : server.status === 'disabled' ? '已禁用' : '错误'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** MCP 服务卡片(全局与项目共用)。 */
|
|
42
|
+
function ServerCard({ server, onRefresh, onToggle, onRemove }) {
|
|
43
|
+
return React.createElement('article', { className: 'vsm-mcp-card' },
|
|
44
|
+
React.createElement('div', { className: 'vsm-mcp-card-head' },
|
|
45
|
+
React.createElement('div', { className: 'vsm-mcp-title' }, React.createElement('span', { className: 'vsm-mcp-dot ' + (server.enabled ? server.status : 'disabled') }), server.serverName),
|
|
46
|
+
React.createElement('div', { className: 'vsm-mcp-actions' },
|
|
47
|
+
React.createElement('button', { onClick: () => onRefresh(server.id), title: '刷新连接' }, '↻'),
|
|
48
|
+
React.createElement('button', { className: 'vsm-danger', onClick: () => onRemove(server.id), title: '删除 MCP' }, '⌫'),
|
|
49
|
+
React.createElement('button', { className: 'vsm-switch ' + (server.enabled ? 'on' : ''), onClick: () => onToggle(server), 'aria-label': server.enabled ? '禁用' : '启用' }, server.enabled ? '●' : '○'),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
React.createElement('div', { className: 'vsm-mcp-meta' }, statusOf(server), ' · ', server.toolCount, ' 个工具 · ', server.transport),
|
|
53
|
+
server.error && React.createElement('div', { className: 'vsm-mcp-error' }, server.error),
|
|
54
|
+
React.createElement('div', { className: 'vsm-mcp-tools' }, server.tools.map((tool) => React.createElement('span', { key: tool.name, className: 'vsm-mcp-chip', title: tool.description || tool.name }, tool.name))),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 按项目标题或路径过滤,大小写不敏感。 */
|
|
59
|
+
export function filterProjects(projects, query) {
|
|
60
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
61
|
+
if (!needle) return projects
|
|
62
|
+
return projects.filter((project) => `${project.title} ${project.workspacePath}`.toLowerCase().includes(needle))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 带搜索的项目组合框。 */
|
|
66
|
+
function ProjectPicker({ projects, value, onChange }) {
|
|
67
|
+
const [open, setOpen] = React.useState(false)
|
|
68
|
+
const [query, setQuery] = React.useState('')
|
|
69
|
+
const [highlight, setHighlight] = React.useState(0)
|
|
70
|
+
const rootRef = React.useRef(null)
|
|
71
|
+
const selected = projects.find((project) => project.workspacePath === value)
|
|
72
|
+
const filtered = React.useMemo(() => filterProjects(projects, query), [projects, query])
|
|
73
|
+
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
const close = (event) => {
|
|
76
|
+
if (!rootRef.current?.contains(event.target)) setOpen(false)
|
|
77
|
+
}
|
|
78
|
+
document.addEventListener('mousedown', close)
|
|
79
|
+
return () => document.removeEventListener('mousedown', close)
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const choose = (project) => {
|
|
83
|
+
onChange(project.workspacePath)
|
|
84
|
+
setQuery('')
|
|
85
|
+
setOpen(false)
|
|
86
|
+
}
|
|
87
|
+
const move = (delta) => setHighlight((old) => Math.max(0, Math.min(Math.max(0, filtered.length - 1), old + delta)))
|
|
88
|
+
const keyDown = (event) => {
|
|
89
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); move(1) }
|
|
90
|
+
else if (event.key === 'ArrowUp') { event.preventDefault(); move(-1) }
|
|
91
|
+
else if (event.key === 'Enter' && filtered[highlight]) { event.preventDefault(); choose(filtered[highlight]) }
|
|
92
|
+
else if (event.key === 'Escape') { event.preventDefault(); setOpen(false) }
|
|
93
|
+
}
|
|
94
|
+
return React.createElement('div', { className: 'vsm-project-picker', ref: rootRef },
|
|
95
|
+
React.createElement('button', { className: 'vsm-project-picker-trigger', role: 'combobox', 'aria-expanded': open, onClick: () => setOpen((old) => !old) },
|
|
96
|
+
React.createElement('span', { className: 'vsm-project-picker-label' }, selected?.title || '选择项目', selected && React.createElement('small', null, selected.workspacePath)),
|
|
97
|
+
React.createElement('span', { className: 'vsm-project-picker-arrow' }, open ? '⌃' : '⌄'),
|
|
98
|
+
),
|
|
99
|
+
open && React.createElement('div', { className: 'vsm-project-picker-menu' },
|
|
100
|
+
React.createElement('input', { autoFocus: true, className: 'vsm-project-picker-search', value: query, placeholder: '搜索项目名称或路径…', onChange: (event) => { setQuery(event.target.value); setHighlight(0) }, onKeyDown: keyDown }),
|
|
101
|
+
React.createElement('div', { className: 'vsm-project-picker-options', role: 'listbox' }, filtered.length ? filtered.map((project, index) => React.createElement('button', { key: project.workspacePath, role: 'option', 'aria-selected': project.workspacePath === value, className: 'vsm-project-option ' + (index === highlight ? 'highlight' : '') + (project.workspacePath === value ? ' selected' : ''), onMouseEnter: () => setHighlight(index), onClick: () => choose(project) }, React.createElement('span', null, project.title), React.createElement('small', null, project.workspacePath, ' · ', project.servers.length, ' 个 MCP'))) : React.createElement('div', { className: 'vsm-project-picker-empty' }, '没有匹配的项目')),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 单个项目的 MCP 分组。 */
|
|
107
|
+
function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove }) {
|
|
108
|
+
const head = React.createElement('div', { className: 'vsm-project-head' },
|
|
109
|
+
React.createElement('div', { className: 'vsm-project-title' }, React.createElement('span', { className: 'vsm-project-icon' }, '▣'), React.createElement('div', null, React.createElement('span', { className: 'vsm-project-name' }, project.title), React.createElement('span', { className: 'vsm-project-path' }, project.workspacePath))),
|
|
110
|
+
React.createElement('button', { className: 'vsm-primary vsm-small', onClick: () => onAdd(project) }, '+ 添加 MCP'),
|
|
111
|
+
)
|
|
112
|
+
let body
|
|
113
|
+
if (project.missingDir) body = React.createElement('div', { className: 'vsm-project-empty' }, '项目目录已不存在,无法管理 MCP')
|
|
114
|
+
else if (project.fileError) body = React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, project.fileError)
|
|
115
|
+
else if (!project.servers.length) body = React.createElement('div', { className: 'vsm-project-empty' }, '此项目未配置 MCP')
|
|
116
|
+
else body = project.servers.map((server) => React.createElement(ServerCard, { key: server.serverName, server, onRefresh: (id) => onRefresh(project, server, id), onToggle: () => onToggle(project, server), onRemove: (id) => onRemove(project, server, id) }))
|
|
117
|
+
return React.createElement('section', { className: 'vsm-project' }, head, body)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** MCP 管理主面板。 */
|
|
121
|
+
export function McpSettings() {
|
|
122
|
+
const [servers, setServers] = React.useState<MpcServer[]>([])
|
|
123
|
+
const [projects, setProjects] = React.useState<MpcProject[]>([])
|
|
124
|
+
const [loading, setLoading] = React.useState(true)
|
|
125
|
+
const [busy, setBusy] = React.useState('')
|
|
126
|
+
const [error, setError] = React.useState('')
|
|
127
|
+
const [tab, setTab] = React.useState('mine')
|
|
128
|
+
const [draft, setDraft] = React.useState(EMPTY)
|
|
129
|
+
const [showForm, setShowForm] = React.useState(false)
|
|
130
|
+
const [projectForm, setProjectForm] = React.useState(null)
|
|
131
|
+
const [selectedPath, setSelectedPath] = React.useState('')
|
|
132
|
+
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
if (!projects.length) {
|
|
135
|
+
setSelectedPath('')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (!projects.some((project) => project.workspacePath === selectedPath)) setSelectedPath(projects[0].workspacePath)
|
|
139
|
+
}, [projects, selectedPath])
|
|
140
|
+
|
|
141
|
+
const loadAll = React.useCallback(() => {
|
|
142
|
+
setLoading(true)
|
|
143
|
+
setError('')
|
|
144
|
+
Promise.all([rpc('mcp.list', {}), rpc('mcp.projects', {})]).then(([list, prj]) => {
|
|
145
|
+
if (!list.ok) throw new Error(list.error)
|
|
146
|
+
if (!prj.ok) throw new Error(prj.error)
|
|
147
|
+
setServers(list.servers)
|
|
148
|
+
setProjects(prj.projects)
|
|
149
|
+
}).catch((e) => setError(String(e))).finally(() => setLoading(false))
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
React.useEffect(loadAll, [loadAll])
|
|
153
|
+
|
|
154
|
+
const finish = (result) => {
|
|
155
|
+
if (!result.ok) throw new Error(result.error)
|
|
156
|
+
return result
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const action = (label, id, method, args, patch) => {
|
|
160
|
+
setBusy(id)
|
|
161
|
+
setError('')
|
|
162
|
+
return rpc(method, args).then(finish).then((result) => {
|
|
163
|
+
if (patch) patch(result)
|
|
164
|
+
}).catch((e) => setError(String(e))).finally(() => setBusy('')).then(() => label)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const refreshGlobal = (id) => action('refresh', id, 'mcp.refresh', { id }, (r) => setServers((old) => old.map((s) => s.id === id ? r.server : s)))
|
|
168
|
+
const toggleGlobal = (s) => action('toggle', s.id, 'mcp.toggle', { id: s.id, enabled: !s.enabled }, (r) => setServers((old) => old.map((x) => x.id === s.id ? r.server : x)))
|
|
169
|
+
const removeGlobal = (s) => window.confirm('确认删除此 MCP 服务?') && action('remove', s.id, 'mcp.remove', { id: s.id }, (r) => setServers((old) => old.filter((x) => x.id !== s.id)))
|
|
170
|
+
|
|
171
|
+
const replaceProject = (workspacePath) => (result) => setProjects((old) => old.map((p) => p.workspacePath === workspacePath ? result.project : p))
|
|
172
|
+
const projectAction = (workspacePath, serverName, method, args, patch) => {
|
|
173
|
+
setBusy(workspacePath + ':' + serverName)
|
|
174
|
+
setError('')
|
|
175
|
+
return rpc(method, args).then(finish).then((result) => {
|
|
176
|
+
if (patch) patch(result)
|
|
177
|
+
}).catch((e) => setError(String(e))).finally(() => setBusy(''))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const refreshProject = (p, s) => projectAction(p.workspacePath, s.serverName, 'mcp.projectRefresh', { workspacePath: p.workspacePath, serverName: s.serverName }, replaceProject(p.workspacePath))
|
|
181
|
+
const toggleProject = (p, s) => projectAction(p.workspacePath, s.serverName, 'mcp.projectToggle', { workspacePath: p.workspacePath, serverName: s.serverName, enabled: !s.enabled }, replaceProject(p.workspacePath))
|
|
182
|
+
const removeProject = (p, s) => window.confirm('确认删除此项目的 MCP「' + s.serverName + '」?') && projectAction(p.workspacePath, s.serverName, 'mcp.projectRemove', { workspacePath: p.workspacePath, serverName: s.serverName }, replaceProject(p.workspacePath))
|
|
183
|
+
|
|
184
|
+
const saveGlobal = () => {
|
|
185
|
+
setBusy('save')
|
|
186
|
+
setError('')
|
|
187
|
+
rpc('mcp.save', { config: configOf(draft) }).then(finish).then((result) => {
|
|
188
|
+
setServers((old) => old.some((s) => s.id === result.server.id) ? old.map((s) => s.id === result.server.id ? result.server : s) : old.concat(result.server))
|
|
189
|
+
setDraft(EMPTY)
|
|
190
|
+
setShowForm(false)
|
|
191
|
+
}).catch((e) => setError(String(e))).finally(() => setBusy(''))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const saveProject = () => {
|
|
195
|
+
if (!projectForm) return
|
|
196
|
+
setBusy('project-save')
|
|
197
|
+
setError('')
|
|
198
|
+
const config = configOf(draft)
|
|
199
|
+
rpc('mcp.projectSave', { workspacePath: projectForm.workspacePath, serverName: config.serverName, config }).then(finish).then((result) => {
|
|
200
|
+
setProjects((old) => old.map((p) => p.workspacePath === projectForm.workspacePath ? result.project : p))
|
|
201
|
+
setDraft(EMPTY)
|
|
202
|
+
setProjectForm(null)
|
|
203
|
+
}).catch((e) => setError(String(e))).finally(() => setBusy(''))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const edit = (key, value) => setDraft((old) => ({ ...old, [key]: value }))
|
|
207
|
+
const marketBody = React.createElement('div', { className: 'vsm-mcp-empty' }, 'MCP 市场暂未接入')
|
|
208
|
+
const selectedProject = projects.find((project) => project.workspacePath === selectedPath)
|
|
209
|
+
const projectBody = projects.length ? React.createElement(React.Fragment, null,
|
|
210
|
+
React.createElement(ProjectPicker, { projects, value: selectedPath, onChange: setSelectedPath }),
|
|
211
|
+
selectedProject ? React.createElement(ProjectGroup, { project: selectedProject, busy, onAdd: (project) => { setDraft(EMPTY); setProjectForm({ workspacePath: project.workspacePath, title: project.title }) }, onRefresh: refreshProject, onToggle: toggleProject, onRemove: removeProject }) : React.createElement('div', { className: 'vsm-mcp-empty' }, '请选择项目'),
|
|
212
|
+
) : React.createElement('div', { className: 'vsm-mcp-empty' }, '还没有项目')
|
|
213
|
+
let body
|
|
214
|
+
if (loading) body = React.createElement('div', { className: 'vsm-mcp-empty' }, '正在读取 MCP 服务…')
|
|
215
|
+
else if (tab === 'mine') body = React.createElement('div', null, servers.length ? servers.map((s) => React.createElement(ServerCard, { key: s.id, server: s, onRefresh: refreshGlobal, onToggle: toggleGlobal, onRemove: removeGlobal })) : React.createElement('div', { className: 'vsm-mcp-empty' }, '还没有配置全局 MCP'))
|
|
216
|
+
else if (tab === 'projects') body = projectBody
|
|
217
|
+
else body = marketBody
|
|
218
|
+
const form = showForm ? React.createElement(McpForm, { title: '添加全局 MCP', draft, busy, edit, save: saveGlobal, close: () => setShowForm(false) }) : projectForm ? React.createElement(McpForm, { title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: saveProject, close: () => setProjectForm(null) }) : null
|
|
219
|
+
return React.createElement('section', { className: 'vsm-mcp-page' },
|
|
220
|
+
React.createElement('header', { className: 'vsm-mcp-header' }, React.createElement('div', null, React.createElement('h2', null, 'VSCodeMode'), React.createElement('p', null, '管理当前 profile 与各项目的 Model Context Protocol 服务。')), React.createElement('button', { className: 'vsm-primary', onClick: () => { setDraft(EMPTY); setShowForm(true) } }, '+ 添加全局 MCP')),
|
|
221
|
+
React.createElement('nav', { className: 'vsm-mcp-tabs' }, React.createElement('button', { className: tab === 'mine' ? 'active' : '', onClick: () => setTab('mine') }, '我的 MCP'), React.createElement('button', { className: tab === 'projects' ? 'active' : '', onClick: () => setTab('projects') }, '项目 MCP'), React.createElement('button', { className: tab === 'market' ? 'active' : '', onClick: () => setTab('market') }, 'MCP 市场')),
|
|
222
|
+
error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
|
|
223
|
+
body,
|
|
224
|
+
form,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** MCP 新增表单(全局/项目共用)。 */
|
|
229
|
+
function McpForm({ title, draft, busy, edit, save, close }) {
|
|
230
|
+
const input = (key, placeholder) => React.createElement('input', { value: draft[key], onChange: (e) => edit(key, e.target.value), placeholder })
|
|
231
|
+
const fields = draft.transport === 'stdio' ? React.createElement(React.Fragment, null, React.createElement('label', null, '命令', input('command', 'npx')), React.createElement('label', null, '参数(空格或换行分隔)', React.createElement('textarea', { value: draft.args, onChange: (e) => edit('args', e.target.value) })), React.createElement('label', null, '工作目录(可选)', input('cwd', ''))) : React.createElement(React.Fragment, null, React.createElement('label', null, 'URL', input('url', 'http://localhost:3000/mcp')), React.createElement('label', null, '请求头(每行 key=value)', React.createElement('textarea', { value: draft.headers, onChange: (e) => edit('headers', e.target.value) })))
|
|
232
|
+
return React.createElement('div', { className: 'vsm-mcp-modal' }, React.createElement('div', { className: 'vsm-mcp-dialog' }, React.createElement('h3', null, title), React.createElement('label', null, '名称', input('serverName', '例如 codegraph')), React.createElement('label', null, '传输方式', React.createElement('select', { value: draft.transport, onChange: (e) => edit('transport', e.target.value) }, React.createElement('option', { value: 'stdio' }, 'stdio'), React.createElement('option', { value: 'streamable-http' }, 'streamable-http'))), fields, React.createElement('div', { className: 'vsm-mcp-dialog-actions' }, React.createElement('button', { onClick: close }, '取消'), React.createElement('button', { className: 'vsm-primary', disabled: busy === 'save' || busy === 'project-save', onClick: save }, '保存并连接'))))
|
|
233
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import { registerRoutes } from './routes.js'
|
|
9
9
|
import { captureToolResult } from './capture.js'
|
|
10
10
|
import { handleRpc } from './rpc.js'
|
|
11
|
+
import { installIsolation } from './mcpIsolation.js'
|
|
11
12
|
import { dropFileIndex } from './workspace.js'
|
|
12
13
|
import { cwdOf } from './registry.js'
|
|
13
14
|
import type { Registry } from './registry.js'
|
|
14
15
|
import type { Ctx } from './store.js'
|
|
15
16
|
|
|
16
17
|
export const name = "dsh-vscode-mode"
|
|
17
|
-
export const inject = ['sessions', 'fs', 'webServer']
|
|
18
|
+
export const inject = ['sessions', 'fs', 'webServer', 'loader', 'tools', 'workspaceRegistry', 'agents']
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* 装配插件:挂事件监听、注册路由。
|
|
@@ -38,6 +39,7 @@ export function apply(ctx: Ctx, config?: unknown): void {
|
|
|
38
39
|
})
|
|
39
40
|
|
|
40
41
|
registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args))
|
|
42
|
+
installIsolation(ctx)
|
|
41
43
|
|
|
42
|
-
ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc
|
|
44
|
+
ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)')
|
|
43
45
|
}
|
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
|
+
}
|