dsh-vscode-mode 0.14.3 → 0.14.4
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 +11 -9
- package/lib/client.js +488 -180
- package/lib/client.js.map +1 -1
- package/lib/index.js +242 -53
- package/lib/index.js.map +1 -1
- package/package.json +7 -7
- package/skills/dsh-vscodemode-mcp/SKILL.md +9 -3
- package/src/client/diffDockStore.ts +8 -3
- package/src/client/mcpForm.ts +79 -0
- package/src/client/mcpState.ts +179 -0
- package/src/client/styles/mcp.css +6 -3
- package/src/client/ui/McpSettings.ts +174 -137
- package/src/dshVersion.ts +4 -3
- package/src/mcp.ts +131 -23
- package/src/mcpIsolation.ts +2 -1
- package/src/mcpProject.ts +74 -14
- package/src/mcpRuntime.ts +18 -3
- package/src/rpc.ts +2 -1
- package/src/shared/mcpStatus.ts +30 -0
- package/src/shared/rpc.ts +2 -0
package/src/mcp.ts
CHANGED
|
@@ -13,6 +13,49 @@ import {
|
|
|
13
13
|
import type { MpcConfig, MpcServer, MpcTool } from './shared/mcp.js'
|
|
14
14
|
import type { Ctx } from './store.js'
|
|
15
15
|
import { checkMcpName } from './mcpProject.js'
|
|
16
|
+
import { runtimeOf } from './mcpRuntime.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compare the official tool-name namespaces without guessing the longest prefix.
|
|
20
|
+
* @public @author ddj 2026年10月08号
|
|
21
|
+
* @param left First server name.
|
|
22
|
+
* @param right Second server name.
|
|
23
|
+
* @returns Whether distinct names generate overlapping public tool names.
|
|
24
|
+
*/
|
|
25
|
+
export function nameOverlap(left: string, right: string): boolean {
|
|
26
|
+
return left !== right && (left.startsWith(right + '__') || right.startsWith(left + '__'))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect known global and project namespaces without opening connections.
|
|
31
|
+
* @public @author ddj 2026年10月08号
|
|
32
|
+
* @param ctx Host context.
|
|
33
|
+
* @returns Distinct server names used for fail-closed attribution.
|
|
34
|
+
*/
|
|
35
|
+
export function mcpNames(ctx: Ctx): Set<string> {
|
|
36
|
+
const names = new Set(runtimeOf(ctx).owners().keys())
|
|
37
|
+
for (const entry of entriesOf(ctx)) {
|
|
38
|
+
const name = entry.options?.config?.serverName
|
|
39
|
+
if (typeof name === 'string') names.add(name)
|
|
40
|
+
}
|
|
41
|
+
return names
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const edits = new WeakMap<object, Promise<unknown>>()
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Serialize profile/project edits together to preserve files and cross-scope name reservations.
|
|
48
|
+
* @public @author ddj 2026年10月08号
|
|
49
|
+
* @param ctx Host owner.
|
|
50
|
+
* @param action Validated configuration operation.
|
|
51
|
+
* @returns Operation result; a rejection never poisons the next edit.
|
|
52
|
+
*/
|
|
53
|
+
export function mcpEdit<T>(ctx: Ctx, action: () => Promise<T>): Promise<T> {
|
|
54
|
+
const owner = ctx.root ?? ctx
|
|
55
|
+
const next = (edits.get(owner) ?? Promise.resolve()).then(action, action)
|
|
56
|
+
edits.set(owner, next)
|
|
57
|
+
return next
|
|
58
|
+
}
|
|
16
59
|
|
|
17
60
|
export { LEGACY_PROJECT_PREFIX, MCP_PACKAGE, PROJECT_ENTRY_PREFIX, isProjectEntryId }
|
|
18
61
|
|
|
@@ -46,7 +89,11 @@ export function toolsOf(ctx: Ctx, serverName: string, agent?: any): MpcTool[] {
|
|
|
46
89
|
const visible = view?.visible
|
|
47
90
|
if (!(visible instanceof Map)) return []
|
|
48
91
|
const prefix = `mcp__${serverName}__`
|
|
49
|
-
|
|
92
|
+
const namespaces = mcpNames(ctx)
|
|
93
|
+
namespaces.add(serverName)
|
|
94
|
+
const belongs = (name: string) => name.startsWith(prefix)
|
|
95
|
+
&& [...namespaces].filter((server) => name.startsWith('mcp__' + server + '__')).length === 1
|
|
96
|
+
return [...visible.entries()].filter(([name]) => belongs(String(name))).map(([name, definition]) => ({
|
|
50
97
|
name: String(name).slice(prefix.length),
|
|
51
98
|
description: typeof definition?.description === 'string' ? definition.description : undefined,
|
|
52
99
|
}))
|
|
@@ -76,6 +123,20 @@ export function mergeSecrets(next: Record<string, unknown>, previous: Record<str
|
|
|
76
123
|
return merged
|
|
77
124
|
}
|
|
78
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Merge editable transport fields while retaining advanced settings and secret values.
|
|
128
|
+
* @public @author ddj 2026年10月08号
|
|
129
|
+
* @param previous Last saved configuration.
|
|
130
|
+
* @param config Submitted transport configuration.
|
|
131
|
+
* @returns Detached configuration without fields belonging to the old transport.
|
|
132
|
+
*/
|
|
133
|
+
export function mergeMcp(previous: Record<string, unknown>, config: MpcConfig): Record<string, unknown> {
|
|
134
|
+
const next = mergeSecrets({ ...previous, ...config }, previous)
|
|
135
|
+
const stale = config.transport === 'stdio' ? ['url', 'headers'] : ['command', 'args', 'cwd', 'env']
|
|
136
|
+
for (const key of stale) delete next[key]
|
|
137
|
+
return next
|
|
138
|
+
}
|
|
139
|
+
|
|
79
140
|
/**
|
|
80
141
|
* Summarize a global entry without treating plugin activation as a successful handshake.
|
|
81
142
|
* @public @author ddj 2026年09月28号
|
|
@@ -103,11 +164,40 @@ export function serverOf(ctx: Ctx, entry: any): MpcServer {
|
|
|
103
164
|
}
|
|
104
165
|
}
|
|
105
166
|
|
|
106
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Validate transport fields before any loader or source mutation.
|
|
169
|
+
* @public @author ddj 2026年10月08号
|
|
170
|
+
* @param config Submitted configuration.
|
|
171
|
+
* @throws Error for unsupported transport or malformed endpoint/arguments/maps.
|
|
172
|
+
*/
|
|
107
173
|
export function validateConfig(config: MpcConfig): void {
|
|
108
|
-
if (
|
|
109
|
-
if (config.
|
|
110
|
-
if (
|
|
174
|
+
if (!config || typeof config !== 'object') throw new Error('MCP 配置必须是对象')
|
|
175
|
+
if (typeof config.serverName !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(config.serverName)) throw new Error('serverName 只能包含字母、数字、下划线和连字符(最多 32 位)')
|
|
176
|
+
if (!['stdio', 'streamable-http'].includes(config.transport)) throw new Error('不支持的 MCP transport')
|
|
177
|
+
if (config.transport === 'stdio') {
|
|
178
|
+
if (typeof config.command !== 'string' || !config.command.trim()) throw new Error('stdio MCP 必须填写 command')
|
|
179
|
+
if (config.args !== undefined && (!Array.isArray(config.args) || config.args.some((arg) => typeof arg !== 'string'))) throw new Error('MCP args 必须是字符串数组')
|
|
180
|
+
if (config.cwd !== undefined && typeof config.cwd !== 'string') throw new Error('MCP cwd 必须是字符串')
|
|
181
|
+
validatePairs(config.env, 'env')
|
|
182
|
+
} else {
|
|
183
|
+
let url: URL
|
|
184
|
+
try { url = new URL(config.url ?? '') } catch { throw new Error('HTTP MCP 必须填写有效 http(s) URL') }
|
|
185
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error('HTTP MCP 必须填写有效 http(s) URL,凭据请使用请求头')
|
|
186
|
+
validatePairs(config.headers, 'headers')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate an optional string-valued map without coercing malformed input.
|
|
192
|
+
* @private @author ddj 2026年10月08号
|
|
193
|
+
* @param values Submitted map.
|
|
194
|
+
* @param field Field used in the diagnostic.
|
|
195
|
+
* @throws Error for non-object or non-string values.
|
|
196
|
+
*/
|
|
197
|
+
function validatePairs(values: unknown, field: string): void {
|
|
198
|
+
if (values === undefined) return
|
|
199
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)
|
|
200
|
+
|| Object.entries(values).some(([key, value]) => !key || typeof value !== 'string')) throw new Error('MCP ' + field + ' 必须是字符串字典')
|
|
111
201
|
}
|
|
112
202
|
|
|
113
203
|
/** 列出全局(profile)MCP 服务,过滤掉新旧格式的项目级条目。 */
|
|
@@ -123,13 +213,18 @@ export function listMcp(ctx: Ctx): { servers: MpcServer[] } {
|
|
|
123
213
|
* @returns Masked server status; project name collisions reject before writing.
|
|
124
214
|
*/
|
|
125
215
|
export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
|
|
216
|
+
return mcpEdit(ctx, () => saveGlobal(ctx, config))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param config Configuration. @returns Saved status; validation failures do not write. */
|
|
220
|
+
async function saveGlobal(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
|
|
126
221
|
validateConfig(config)
|
|
127
222
|
await checkMcpName(ctx, config.serverName)
|
|
128
223
|
const loader = ctx.get('loader')
|
|
129
224
|
if (!loader) throw new Error('缺少 loader 服务')
|
|
130
225
|
const existing = entriesOf(ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
|
|
131
226
|
const previous = existing?.options?.config ?? {}
|
|
132
|
-
const next =
|
|
227
|
+
const next = mergeMcp(previous, config)
|
|
133
228
|
if (existing) {
|
|
134
229
|
await loader.update(existing.id, { config: next })
|
|
135
230
|
return serverOf(ctx, loader.resolve(existing.id))
|
|
@@ -138,33 +233,46 @@ export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
|
|
|
138
233
|
return serverOf(ctx, loader.resolve(id))
|
|
139
234
|
}
|
|
140
235
|
|
|
141
|
-
/**
|
|
142
|
-
|
|
236
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Loader and verified global entry. @throws Missing service, entry or wrong scope. */
|
|
237
|
+
function globalEntry(ctx: Ctx, id: string): { loader: any; entry: any } {
|
|
143
238
|
const loader = ctx.get('loader')
|
|
144
239
|
if (!loader) throw new Error('缺少 loader 服务')
|
|
145
240
|
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
|
|
146
241
|
if (!entry) throw new Error('MCP 服务不存在')
|
|
147
|
-
|
|
242
|
+
if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))) throw new Error('项目 MCP 必须通过项目入口操作')
|
|
243
|
+
return { loader, entry }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Persisted removal, serialized with all settings writes. */
|
|
247
|
+
export async function removeMcp(ctx: Ctx, id: string): Promise<void> {
|
|
248
|
+
/** @private @author ddj 2026年10月08号 @returns Loader removal after scope validation. */
|
|
249
|
+
const remove = async () => {
|
|
250
|
+
const { loader, entry } = globalEntry(ctx, id)
|
|
251
|
+
await loader.remove(entry.id)
|
|
252
|
+
}
|
|
253
|
+
return mcpEdit(ctx, remove)
|
|
148
254
|
}
|
|
149
255
|
|
|
150
|
-
/**
|
|
256
|
+
/** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @param enabled Desired state. @returns Persisted status after serialized toggle. */
|
|
151
257
|
export async function toggleMcp(ctx: Ctx, id: string, enabled: boolean): Promise<MpcServer> {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
258
|
+
/** @private @author ddj 2026年10月08号 @returns Loader status after toggle. */
|
|
259
|
+
const toggle = async () => {
|
|
260
|
+
const { loader, entry } = globalEntry(ctx, id)
|
|
261
|
+
await loader.update(entry.id, { disabled: !enabled })
|
|
262
|
+
return serverOf(ctx, loader.resolve(entry.id))
|
|
263
|
+
}
|
|
264
|
+
return mcpEdit(ctx, toggle)
|
|
158
265
|
}
|
|
159
266
|
|
|
160
|
-
/**
|
|
267
|
+
/** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Loader status after serialized refresh. */
|
|
161
268
|
export async function refreshMcp(ctx: Ctx, id: string): Promise<MpcServer> {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
269
|
+
/** @private @author ddj 2026年10月08号 @returns Loader status after refresh. */
|
|
270
|
+
const refresh = async () => {
|
|
271
|
+
const { loader, entry } = globalEntry(ctx, id)
|
|
272
|
+
await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
|
|
273
|
+
return serverOf(ctx, loader.resolve(entry.id))
|
|
274
|
+
}
|
|
275
|
+
return mcpEdit(ctx, refresh)
|
|
168
276
|
}
|
|
169
277
|
|
|
170
278
|
export type { MpcConfig, MpcServer }
|
package/src/mcpIsolation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Project MCP visibility, instruction shadows, and execution guards. @author ddj 2026年09月28号 */
|
|
2
2
|
import { posix, win32 } from 'node:path'
|
|
3
3
|
import { LEGACY_PROJECT_PREFIX, entryHash, isProjectEntryId } from './compat.js'
|
|
4
|
-
import { entriesOf } from './mcp.js'
|
|
4
|
+
import { entriesOf, mcpNames } from './mcp.js'
|
|
5
5
|
import { hashWorkspace } from './mcpProject.js'
|
|
6
6
|
import { runtimeOf } from './mcpRuntime.js'
|
|
7
7
|
import type { Ctx } from './store.js'
|
|
@@ -101,6 +101,7 @@ export function guardMcp(ctx: Ctx, exec: any): string | undefined {
|
|
|
101
101
|
const server = callServer(exec)
|
|
102
102
|
const owners = serverOwners(ctx)
|
|
103
103
|
const current = runtimeOf(ctx).workspace(exec.agent)
|
|
104
|
+
if (!RESOURCE_TOOLS.has(name) && [...mcpNames(ctx)].filter((key) => name.startsWith('mcp__' + key + '__')).length > 1) return '已拒绝:MCP 命名空间冲突,无法确认工具归属'
|
|
104
105
|
const owned = RESOURCE_TOOLS.has(name) ? [...owners].filter(([key]) => key === server)
|
|
105
106
|
: [...owners].filter(([key]) => name.startsWith('mcp__' + key + '__'))
|
|
106
107
|
if (!owned.some(([, owner]) => owner !== current)) return undefined
|
package/src/mcpProject.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Project MCP configuration; .mcp.json is the source of truth. @author ddj 2026年09月28号 */
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { PROJECT_ENTRY_PREFIX, entriesOf,
|
|
3
|
+
import { PROJECT_ENTRY_PREFIX, entriesOf, mergeMcp, validateConfig, nameOverlap, listMcp, mcpEdit } from './mcp.js'
|
|
4
|
+
|
|
4
5
|
import { isProjectEntryId } from './compat.js'
|
|
5
6
|
import { clearLegacy, runtimeOf } from './mcpRuntime.js'
|
|
6
7
|
import type { ProjectDef } from './mcpRuntime.js'
|
|
@@ -8,6 +9,7 @@ import type { MpcConfig, MpcProject, MpcServer } from './shared/mcp.js'
|
|
|
8
9
|
import type { Ctx } from './store.js'
|
|
9
10
|
|
|
10
11
|
const FILE_NAME = '.mcp.json'
|
|
12
|
+
const projectStates = new WeakMap<object, Map<string, { fileError?: string; missingDir?: boolean }>>()
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Require an exact registered workspace before any project file mutation.
|
|
@@ -54,7 +56,18 @@ export function projectEntryId(workspacePath: string, serverName: string): strin
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
*
|
|
59
|
+
* Identify an absent target using official host and legacy Node filesystem codes.
|
|
60
|
+
* @private @author ddj 2026年10月08号
|
|
61
|
+
* @param error Filesystem failure; messages and class identity are not authoritative.
|
|
62
|
+
* @returns Whether the backend explicitly reports a missing target.
|
|
63
|
+
*/
|
|
64
|
+
function isMissingFile(error: unknown): boolean {
|
|
65
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
66
|
+
return code === 'FS_NOT_FOUND' || code === 'ENOENT'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the complete source document; only missing-target codes denote an absent configuration.
|
|
58
71
|
* @private @author ddj 2026年09月28号
|
|
59
72
|
* @param ctx Host filesystem.
|
|
60
73
|
* @param workspacePath Registered workspace path.
|
|
@@ -68,7 +81,7 @@ async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<
|
|
|
68
81
|
try {
|
|
69
82
|
text = await fs.readText(target)
|
|
70
83
|
} catch (error) {
|
|
71
|
-
if ((error
|
|
84
|
+
if (isMissingFile(error)) return {}
|
|
72
85
|
throw new Error('.mcp.json 读取失败:' + String(error))
|
|
73
86
|
}
|
|
74
87
|
let data: unknown
|
|
@@ -108,7 +121,14 @@ export function serversOf(data: Record<string, unknown>): Record<string, Record<
|
|
|
108
121
|
*/
|
|
109
122
|
export function configFromDef(def: Record<string, unknown> | undefined, serverName: string): MpcConfig | null {
|
|
110
123
|
if (!def) return null
|
|
111
|
-
|
|
124
|
+
if (def.transport !== undefined && !['stdio', 'streamable-http'].includes(def.transport as string)) throw new Error('不支持的 MCP transport:' + serverName)
|
|
125
|
+
if (def.args !== undefined && (!Array.isArray(def.args) || def.args.some((arg) => typeof arg !== 'string'))) throw new Error('MCP args 必须是字符串数组:' + serverName)
|
|
126
|
+
for (const field of ['env', 'headers']) {
|
|
127
|
+
const values = def[field]
|
|
128
|
+
if (values !== undefined && (!values || typeof values !== 'object' || Array.isArray(values)
|
|
129
|
+
|| Object.values(values).some((value) => value === null || !['string', 'number', 'boolean'].includes(typeof value)))) throw new Error('MCP ' + field + ' 必须是标量字典:' + serverName)
|
|
130
|
+
}
|
|
131
|
+
const transport = def.transport as MpcConfig['transport'] | undefined ?? (typeof def.url === 'string' ? 'streamable-http' : 'stdio')
|
|
112
132
|
const config: MpcConfig = { serverName, transport }
|
|
113
133
|
if (transport === 'stdio') {
|
|
114
134
|
config.command = typeof def.command === 'string' ? def.command : ''
|
|
@@ -150,16 +170,19 @@ async function writeProjectJson(ctx: Ctx, workspacePath: string, data: Record<st
|
|
|
150
170
|
* @returns Completion; throws on collision or an unreadable competing source document.
|
|
151
171
|
*/
|
|
152
172
|
export async function checkMcpName(ctx: Ctx, serverName: string, workspacePath?: string): Promise<void> {
|
|
153
|
-
|
|
173
|
+
/** @private @author ddj 2026年10月08号 @param other Existing name. @param sameOwner Whether updates share ownership. @throws Error for ambiguous namespaces. */
|
|
174
|
+
const checkName = (other: string, sameOwner: boolean): void => {
|
|
175
|
+
if (nameOverlap(serverName, other)) throw new Error('MCP 命名空间冲突:' + serverName + ' / ' + other + ',请避免 __ 前缀交叠')
|
|
176
|
+
if (other === serverName && !sameOwner) throw new Error('serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称')
|
|
177
|
+
}
|
|
154
178
|
for (const entry of entriesOf(ctx)) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
179
|
+
if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))) continue
|
|
180
|
+
const name = entry.options?.config?.serverName
|
|
181
|
+
if (typeof name === 'string') checkName(name, workspacePath === undefined)
|
|
158
182
|
}
|
|
159
183
|
for (const ws of ctx.get('workspaceRegistry')?.list?.() ?? []) {
|
|
160
|
-
if (ws.path === workspacePath) continue
|
|
161
184
|
const servers = serversOf(await readProjectJson(ctx, ws.path))
|
|
162
|
-
|
|
185
|
+
for (const name of Object.keys(servers)) checkName(name, ws.path === workspacePath)
|
|
163
186
|
}
|
|
164
187
|
}
|
|
165
188
|
|
|
@@ -232,9 +255,13 @@ async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promis
|
|
|
232
255
|
missingDir = !info || info.type !== 'directory'
|
|
233
256
|
if (!missingDir) await reconcileProject(ctx, workspacePath)
|
|
234
257
|
} catch (error) {
|
|
235
|
-
missingDir = (error
|
|
258
|
+
missingDir = isMissingFile(error) || undefined
|
|
236
259
|
fileError = String(error)
|
|
237
260
|
}
|
|
261
|
+
const owner = ctx.root ?? ctx
|
|
262
|
+
const states = projectStates.get(owner) ?? new Map()
|
|
263
|
+
states.set(workspacePath, { missingDir, fileError })
|
|
264
|
+
projectStates.set(owner, states)
|
|
238
265
|
return { workspacePath, title, servers: runtimeOf(ctx).views(workspacePath), source: 'project', missingDir, fileError }
|
|
239
266
|
}
|
|
240
267
|
|
|
@@ -250,6 +277,21 @@ export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }
|
|
|
250
277
|
return { projects }
|
|
251
278
|
}
|
|
252
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Read the current ledger only; polling never reconciles files or starts connections.
|
|
282
|
+
* @public @author ddj 2026年10月08号
|
|
283
|
+
* @param ctx Host services.
|
|
284
|
+
* @returns Masked global/project snapshots with last configuration diagnostics.
|
|
285
|
+
*/
|
|
286
|
+
export function mcpSnapshot(ctx: Ctx): { servers: MpcServer[]; projects: MpcProject[] } {
|
|
287
|
+
const states = projectStates.get(ctx.root ?? ctx)
|
|
288
|
+
const projects: MpcProject[] = (ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string; title?: string }) => ({
|
|
289
|
+
workspacePath: ws.path, title: ws.title ?? '', source: 'project' as const,
|
|
290
|
+
servers: runtimeOf(ctx).views(ws.path), ...states?.get(ws.path),
|
|
291
|
+
}))
|
|
292
|
+
return { ...listMcp(ctx), projects }
|
|
293
|
+
}
|
|
294
|
+
|
|
253
295
|
/**
|
|
254
296
|
* Merge editable fields while retaining advanced fields and unchanged masked credentials.
|
|
255
297
|
* @private @author ddj 2026年09月28号
|
|
@@ -258,12 +300,10 @@ export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }
|
|
|
258
300
|
* @returns Enabled source definition without runtime-only keys or stale transport fields.
|
|
259
301
|
*/
|
|
260
302
|
function mergeDefinition(previous: Record<string, unknown>, config: MpcConfig): Record<string, unknown> {
|
|
261
|
-
const next =
|
|
303
|
+
const next = mergeMcp(previous, config)
|
|
262
304
|
delete next.serverName
|
|
263
305
|
delete next.transport
|
|
264
306
|
delete next.disabled
|
|
265
|
-
const stale = config.transport === 'stdio' ? ['url', 'headers'] : ['command', 'args', 'cwd', 'env']
|
|
266
|
-
for (const key of stale) delete next[key]
|
|
267
307
|
return next
|
|
268
308
|
}
|
|
269
309
|
|
|
@@ -277,6 +317,11 @@ function mergeDefinition(previous: Record<string, unknown>, config: MpcConfig):
|
|
|
277
317
|
* @returns Updated project view; invalid sources or conflicts reject before writing.
|
|
278
318
|
*/
|
|
279
319
|
export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
|
|
320
|
+
return mcpEdit(ctx, () => saveProject(ctx, workspacePath, serverName, config))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @param config Editable fields. @returns Persisted project status. */
|
|
324
|
+
async function saveProject(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
|
|
280
325
|
const workspace = requireWorkspace(ctx, workspacePath)
|
|
281
326
|
validateConfig({ ...config, serverName })
|
|
282
327
|
await checkMcpName(ctx, serverName, workspacePath)
|
|
@@ -297,6 +342,11 @@ export async function projectSave(ctx: Ctx, workspacePath: string, serverName: s
|
|
|
297
342
|
* @returns Updated project view; write failures leave active configuration intact.
|
|
298
343
|
*/
|
|
299
344
|
export async function projectRemove(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
|
|
345
|
+
return mcpEdit(ctx, () => removeProject(ctx, workspacePath, serverName))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @returns Project after persisted removal. */
|
|
349
|
+
async function removeProject(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
|
|
300
350
|
const workspace = requireWorkspace(ctx, workspacePath)
|
|
301
351
|
const data = await readProjectJson(ctx, workspacePath)
|
|
302
352
|
const servers = serversOf(data)
|
|
@@ -316,6 +366,11 @@ export async function projectRemove(ctx: Ctx, workspacePath: string, serverName:
|
|
|
316
366
|
* @returns Updated project view, including retained disabled definitions.
|
|
317
367
|
*/
|
|
318
368
|
export async function projectToggle(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
|
|
369
|
+
return mcpEdit(ctx, () => toggleProject(ctx, workspacePath, serverName, enabled))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @param enabled Desired state. @returns Project after persisted toggle. */
|
|
373
|
+
async function toggleProject(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
|
|
319
374
|
const workspace = requireWorkspace(ctx, workspacePath)
|
|
320
375
|
const data = await readProjectJson(ctx, workspacePath)
|
|
321
376
|
const servers = serversOf(data)
|
|
@@ -337,6 +392,11 @@ export async function projectToggle(ctx: Ctx, workspacePath: string, serverName:
|
|
|
337
392
|
* @returns Updated project view; disabled or idle definitions stay connection-free.
|
|
338
393
|
*/
|
|
339
394
|
export async function projectRefresh(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
|
|
395
|
+
return mcpEdit(ctx, () => refreshProject(ctx, workspacePath, serverName))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @returns Refreshed project status without racing edits. */
|
|
399
|
+
async function refreshProject(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
|
|
340
400
|
const workspace = requireWorkspace(ctx, workspacePath)
|
|
341
401
|
const data = await readProjectJson(ctx, workspacePath)
|
|
342
402
|
if (!Object.hasOwn(serversOf(data), serverName)) throw new Error('项目中不存在该 MCP')
|
package/src/mcpRuntime.ts
CHANGED
|
@@ -114,7 +114,7 @@ export class McpRuntime {
|
|
|
114
114
|
const update = async () => {
|
|
115
115
|
this.projects.set(path, defs)
|
|
116
116
|
this.changed()
|
|
117
|
-
for (const [agent, record] of this.agents) await this.syncAgent(agent, record, refresh)
|
|
117
|
+
for (const [agent, record] of this.agents) await this.syncAgent(agent, record, this.workspace(agent) === path ? refresh : undefined)
|
|
118
118
|
this.changed()
|
|
119
119
|
}
|
|
120
120
|
return this.enqueue(update)
|
|
@@ -196,7 +196,7 @@ export class McpRuntime {
|
|
|
196
196
|
const defs = this.projects.get(path ?? '') ?? new Map<string, ProjectDef>()
|
|
197
197
|
for (const [name, instance] of record.instances) {
|
|
198
198
|
const def = defs.get(name)
|
|
199
|
-
if (def?.enabled && instance.key === JSON.stringify(def.config) && name !== refresh) continue
|
|
199
|
+
if (def?.enabled && instance.path === path && instance.key === JSON.stringify(def.config) && name !== refresh) continue
|
|
200
200
|
await this.stopOne(record, name)
|
|
201
201
|
}
|
|
202
202
|
for (const [name, def] of defs) {
|
|
@@ -206,6 +206,21 @@ export class McpRuntime {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Cache successful module loads while allowing explicit refresh after transient failures.
|
|
211
|
+
* @private @author ddj 2026年10月08号
|
|
212
|
+
* @returns Official plugin module; load failures propagate to the instance diagnostic.
|
|
213
|
+
*/
|
|
214
|
+
private async getModule(): Promise<any> {
|
|
215
|
+
const loading = this.module ??= this.load()
|
|
216
|
+
try {
|
|
217
|
+
return await loading
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (this.module === loading) this.module = undefined
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
209
224
|
/**
|
|
210
225
|
* Mount the official plugin through agent.ctx; ACTIVE is not connection evidence.
|
|
211
226
|
* @private @author ddj 2026年09月28号
|
|
@@ -219,7 +234,7 @@ export class McpRuntime {
|
|
|
219
234
|
const instance: Instance = { key: JSON.stringify(config), path }
|
|
220
235
|
record.instances.set(config.serverName, instance)
|
|
221
236
|
try {
|
|
222
|
-
const officialMcp = await
|
|
237
|
+
const officialMcp = await this.getModule()
|
|
223
238
|
if (!record.live || this.stopped) return
|
|
224
239
|
const global = entriesOf(this.ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
|
|
225
240
|
if (global) throw new Error('serverName 已被全局 MCP 使用:' + config.serverName)
|
package/src/rpc.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { restoreFile, revertCall, revertHunk, revertHunks, type Result } from '.
|
|
|
33
33
|
import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rules.js'
|
|
34
34
|
import { isSnippetFilePath, snippetsEntries, snippetsList, snippetsRead, snippetsRemove, snippetsSave } from './snippets.js'
|
|
35
35
|
import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
|
|
36
|
-
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
|
|
36
|
+
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle, mcpSnapshot } from './mcpProject.js'
|
|
37
37
|
import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS, sectionOf, updateSection } from './fileOpenSettings.js'
|
|
38
38
|
import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
|
|
39
39
|
import { shellMenuRegister, shellMenuRemove, shellMenuStatus } from './integrate.js'
|
|
@@ -1165,6 +1165,7 @@ export function buildHandlers(
|
|
|
1165
1165
|
return { ok: true, from: r.from, to: r.to }
|
|
1166
1166
|
},
|
|
1167
1167
|
'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
|
|
1168
|
+
'mcp.snapshot': async () => ({ ok: true, ...mcpSnapshot(ctx) }),
|
|
1168
1169
|
'mcp.save': async (args) => {
|
|
1169
1170
|
try { return { ok: true, server: await saveMcp(ctx, args.config) } }
|
|
1170
1171
|
catch (error) { return { ok: false, error: String(error) } }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MpcServer } from './mcp.js'
|
|
2
|
+
|
|
3
|
+
type ServerStatus = Pick<MpcServer, 'enabled' | 'status'>
|
|
4
|
+
export type McpTone = 'connected' | 'connecting' | 'disabled' | 'error' | 'neutral'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @public
|
|
8
|
+
* @author ddj 2026年10月08号
|
|
9
|
+
* @description Display lifecycle evidence; enabled only expresses configuration intent, not connectivity.
|
|
10
|
+
* @param server Server lifecycle snapshot.
|
|
11
|
+
* @returns User-facing status label.
|
|
12
|
+
*/
|
|
13
|
+
export function statusOf(server: ServerStatus): string {
|
|
14
|
+
if (!server.enabled || server.status === 'disabled') return '已禁用'
|
|
15
|
+
const labels = { connected: '在线', connecting: '装配中', configured: '已配置,等待会话', unverified: '已加载,连接未确认', error: '错误' }
|
|
16
|
+
return labels[server.status] ?? '状态未知'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @public
|
|
21
|
+
* @author ddj 2026年10月08号
|
|
22
|
+
* @description Use neutral styling unless lifecycle evidence explicitly identifies online, loading or error.
|
|
23
|
+
* @param server Server lifecycle snapshot.
|
|
24
|
+
* @returns CSS tone, independent of configuration-enabled state.
|
|
25
|
+
*/
|
|
26
|
+
export function statusTone(server: ServerStatus): McpTone {
|
|
27
|
+
if (!server.enabled || server.status === 'disabled') return 'disabled'
|
|
28
|
+
if (server.status === 'connected' || server.status === 'connecting' || server.status === 'error') return server.status
|
|
29
|
+
return 'neutral'
|
|
30
|
+
}
|
package/src/shared/rpc.ts
CHANGED
|
@@ -386,6 +386,7 @@ export interface RpcRequestMap {
|
|
|
386
386
|
'edrv.fsCopy': { sessionId?: string; from: string; toDir: string }
|
|
387
387
|
'edrv.fsMove': { sessionId?: string; from: string; toDir: string }
|
|
388
388
|
'mcp.list': {}
|
|
389
|
+
'mcp.snapshot': {}
|
|
389
390
|
'mcp.save': { config: MpcConfig }
|
|
390
391
|
'mcp.remove': { id: string }
|
|
391
392
|
'mcp.toggle': { id: string; enabled: boolean }
|
|
@@ -545,6 +546,7 @@ export interface RpcOkMap {
|
|
|
545
546
|
'edrv.fsCopy': { from: string; to: string }
|
|
546
547
|
'edrv.fsMove': { from: string; to: string }
|
|
547
548
|
'mcp.list': { servers: MpcServer[] }
|
|
549
|
+
'mcp.snapshot': { servers: MpcServer[]; projects: MpcProject[] }
|
|
548
550
|
'mcp.save': { server: MpcServer }
|
|
549
551
|
'mcp.remove': object
|
|
550
552
|
'mcp.toggle': { server: MpcServer }
|