dsh-vscode-mode 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/lib/client.js +578 -49
- package/lib/client.js.map +1 -1
- package/lib/index.js +842 -244
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
- package/src/client/index.ts +15 -0
- package/src/client/mcpSessionState.ts +108 -0
- package/src/client/monaco/lsp/providers.ts +32 -3
- package/src/client/state/modelWarmer.ts +198 -0
- package/src/client/styles/editor.css +5 -1
- package/src/client/ui/EditorPane.ts +16 -4
- package/src/client/ui/EditorView.ts +76 -18
- package/src/client/ui/McpSessionStatus.ts +75 -0
- package/src/client/ui/McpSettings.ts +47 -13
- package/src/mcp.ts +1 -0
- package/src/mcpBridge.ts +163 -0
- package/src/mcpDiscover.ts +38 -0
- package/src/mcpPool.ts +245 -0
- package/src/mcpProject.ts +15 -12
- package/src/mcpRuntime.ts +299 -254
- package/src/rpc.ts +13 -1
- package/src/shared/mcp.ts +47 -0
- package/src/shared/rpc.ts +5 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Scoped capability discovery; never accepts commands, paths or caller identity. @author ddj 2026年10月20号 */
|
|
2
|
+
import type { McpRuntime } from './mcpRuntime.js'
|
|
3
|
+
|
|
4
|
+
/** @public @author ddj 2026年10月20号 @param runtime Authorized runtime. @param ctx Host registration context. @returns Global discovery cleanup; normal capability filters remain authoritative. */
|
|
5
|
+
export function installDiscover(runtime: McpRuntime, ctx: any): () => void {
|
|
6
|
+
const tools = ctx.get('tools')
|
|
7
|
+
/** @author ddj 2026年10月20号 @param args Validated operation. @param execution Registry-owned caller identity. @returns Safe capability catalogue or real connected schemas. */
|
|
8
|
+
const execute = async (args: any, execution: any): Promise<unknown> => {
|
|
9
|
+
const agent = execution.agent
|
|
10
|
+
if (!agent || !runtime.agents.has(agent)) throw new Error('会话未激活,不能使用项目 MCP 发现入口')
|
|
11
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some((key) => !['action', 'serverName'].includes(key))) throw new Error('发现入口仅接受 action 和 serverName')
|
|
12
|
+
if (!['list', 'connect'].includes(args.action)) throw new Error('action 必须为 list 或 connect')
|
|
13
|
+
if (args.action === 'list') return runtime.catalogue(agent)
|
|
14
|
+
if (typeof args.serverName !== 'string') throw new Error('请指定已配置的 serverName')
|
|
15
|
+
return runtime.connect(agent, args.serverName, execution.signal)
|
|
16
|
+
}
|
|
17
|
+
/** @author ddj 2026年10月20号 @param _args Unused. @param result Safe canonical JSON. @returns Native content without bypassing output validation. */
|
|
18
|
+
const render = (_args: unknown, result: unknown): any[] => [{ type: 'text', text: JSON.stringify(result) }]
|
|
19
|
+
const removeTool = tools.register({
|
|
20
|
+
name: 'discover_project_mcp', description: 'List this workspace’s configured project MCP services without starting them, or connect one to discover its real tools. After connecting, call discovered tools in the NEXT request/program; never guess schemas. Global/preset MCPs are not included.',
|
|
21
|
+
parameters: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'connect'] }, serverName: { type: 'string' } }, required: ['action'], additionalProperties: false },
|
|
22
|
+
output: { schema: {}, render }, execute,
|
|
23
|
+
})
|
|
24
|
+
return removeTool
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @public @author ddj 2026年10月20号 @param runtime Active owners. @param agent Scoped catalogue owner. @returns Scoped instruction cleanup; does not register a tool override. */
|
|
28
|
+
export function discoverPrompt(runtime: McpRuntime, agent: any): () => void {
|
|
29
|
+
const prompt = agent.ctx.get('systemPrompt')
|
|
30
|
+
/** @author ddj 2026年10月20号 @returns Current enabled names only; no credential-bearing configuration. */
|
|
31
|
+
const text = (): string => {
|
|
32
|
+
const names = runtime.catalogue(agent).map((server) => server.serverName)
|
|
33
|
+
if (!names.length) return ''
|
|
34
|
+
return 'Project MCP capabilities (may not be started): ' + JSON.stringify(names) + '. Use discover_project_mcp action=list, then action=connect with serverName when needed. Only use returned real tool schemas. Native tools appear on the next model request; PTC must finish the current program and start a new program to refresh its SDK. Do not eval or bypass tool approval.'
|
|
35
|
+
}
|
|
36
|
+
const removePrompt = prompt.section({ name: 'vscode-mode:project-mcp', order: 3090, text, interpolate: false })
|
|
37
|
+
return removePrompt
|
|
38
|
+
}
|
package/src/mcpPool.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/** Connection ownership, bounded cold starts and workspace leases. @author ddj 2026年10月20号 */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { McpBridge } from './mcpBridge.js'
|
|
4
|
+
import type { McpPolicy, McpPhase, MpcConfig } from './shared/mcp.js'
|
|
5
|
+
import type { Ctx } from './store.js'
|
|
6
|
+
|
|
7
|
+
export interface PoolSpec {
|
|
8
|
+
workspace: string
|
|
9
|
+
owner: object
|
|
10
|
+
config: MpcConfig
|
|
11
|
+
policy: McpPolicy
|
|
12
|
+
}
|
|
13
|
+
export interface PoolEntry {
|
|
14
|
+
key: string
|
|
15
|
+
spec: PoolSpec
|
|
16
|
+
refs: number
|
|
17
|
+
phase: McpPhase
|
|
18
|
+
queuedAt: number
|
|
19
|
+
phaseAt: number
|
|
20
|
+
finishedAt?: number
|
|
21
|
+
startAt?: number
|
|
22
|
+
connectedAt?: number
|
|
23
|
+
error?: string
|
|
24
|
+
retryAt?: number
|
|
25
|
+
bridge?: McpBridge
|
|
26
|
+
fiber?: any
|
|
27
|
+
ready: Promise<void>
|
|
28
|
+
close?: Promise<void>
|
|
29
|
+
idle?: ReturnType<typeof setTimeout>
|
|
30
|
+
live: boolean
|
|
31
|
+
calls: Promise<unknown>
|
|
32
|
+
}
|
|
33
|
+
export interface PoolLease {
|
|
34
|
+
entry: PoolEntry
|
|
35
|
+
release(): void
|
|
36
|
+
}
|
|
37
|
+
export type BridgeMaker = () => Promise<McpBridge>
|
|
38
|
+
|
|
39
|
+
/** @private @author ddj 2026年10月20号 @param input Plain effective configuration. @returns Stable secret-bearing identity used only as digest input. */
|
|
40
|
+
function stableConfig(input: unknown): string {
|
|
41
|
+
if (Array.isArray(input)) return '[' + input.map(stableConfig).join(',') + ']'
|
|
42
|
+
if (input && typeof input === 'object') {
|
|
43
|
+
const config = input as Record<string, unknown>
|
|
44
|
+
return '{' + Object.keys(config).sort().map((key) => JSON.stringify(key) + ':' + stableConfig(config[key])).join(',') + '}'
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(input) ?? 'null'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @public @author ddj 2026年10月20号 @param task Shared work that must remain alive for other waiters. @param signal One waiter's cancellation. @returns Caller-local wait without abandoning owned pool work. */
|
|
50
|
+
export function waitMcp<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
51
|
+
if (!signal) return task
|
|
52
|
+
return new Promise<T>((resolve, reject) => {
|
|
53
|
+
/** @author ddj 2026年10月20号 Rejects only this waiter. */
|
|
54
|
+
const abort = (): void => { reject(new Error('MCP 等待已取消')) }
|
|
55
|
+
if (signal.aborted) { abort(); return }
|
|
56
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
57
|
+
task.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The pool owns private scopes independently from any first subscriber. */
|
|
62
|
+
export class McpPool {
|
|
63
|
+
readonly entries = new Map<string, PoolEntry>()
|
|
64
|
+
private readonly owners = new WeakMap<object, number>()
|
|
65
|
+
private ownerSeq = 0
|
|
66
|
+
private running = 0
|
|
67
|
+
private readonly queue: Array<() => Promise<void>> = []
|
|
68
|
+
private readonly jobs = new Set<Promise<void>>()
|
|
69
|
+
private stopped = false
|
|
70
|
+
|
|
71
|
+
/** @public @author ddj 2026年10月20号 @param ctx Host lifecycle owner. @param load Official plugin loader. @param make Private scope factory. */
|
|
72
|
+
constructor(private readonly ctx: Ctx, private readonly load: () => Promise<any>, private readonly make: BridgeMaker = () => McpBridge.create(ctx)) {}
|
|
73
|
+
|
|
74
|
+
/** @private @author ddj 2026年10月20号 @param spec Effective request. @returns Private digest; never emit configuration or secrets. */
|
|
75
|
+
private keyOf(spec: PoolSpec): string {
|
|
76
|
+
let owner = 0
|
|
77
|
+
if (spec.policy.sharing === 'agent') {
|
|
78
|
+
if (!this.owners.has(spec.owner)) this.owners.set(spec.owner, ++this.ownerSeq)
|
|
79
|
+
owner = this.owners.get(spec.owner)!
|
|
80
|
+
}
|
|
81
|
+
const workspace = process.platform === 'win32' ? spec.workspace.replace(/\\/g, '/').toLowerCase() : spec.workspace
|
|
82
|
+
return createHash('sha256').update(stableConfig([workspace, owner, spec.config, spec.policy])).digest('hex')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @public @author ddj 2026年10月20号 @param spec Authorized connection request. @returns Reference-counted single-flight lease. */
|
|
86
|
+
acquire(spec: PoolSpec): PoolLease {
|
|
87
|
+
if (this.stopped) throw new Error('MCP runtime is disposed')
|
|
88
|
+
const key = this.keyOf(spec)
|
|
89
|
+
let entry = this.entries.get(key)
|
|
90
|
+
if (entry?.phase === 'error' && entry.retryAt !== undefined && Date.now() >= entry.retryAt && !entry.close) {
|
|
91
|
+
this.entries.delete(key)
|
|
92
|
+
entry = undefined
|
|
93
|
+
}
|
|
94
|
+
if (entry && !entry.live) throw new Error(entry.error ?? 'MCP 旧连接仍在释放,暂不能创建替代实例')
|
|
95
|
+
if (!entry) {
|
|
96
|
+
entry = { key, spec, refs: 0, phase: 'queued', queuedAt: Date.now(), phaseAt: Date.now(), ready: Promise.resolve(), live: true, calls: Promise.resolve() }
|
|
97
|
+
this.entries.set(key, entry)
|
|
98
|
+
entry.ready = this.schedule(entry)
|
|
99
|
+
void entry.ready.catch(() => undefined)
|
|
100
|
+
}
|
|
101
|
+
if (entry.idle) clearTimeout(entry.idle)
|
|
102
|
+
entry.idle = undefined
|
|
103
|
+
entry.refs += 1
|
|
104
|
+
let released = false
|
|
105
|
+
return { entry, release: () => {
|
|
106
|
+
if (released) return
|
|
107
|
+
released = true
|
|
108
|
+
this.release(entry!)
|
|
109
|
+
} }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @private @author ddj 2026年10月20号 @param entry New entry. @returns Queued initialization, bounded to two concurrent starts. */
|
|
113
|
+
private schedule(entry: PoolEntry): Promise<void> {
|
|
114
|
+
return new Promise<void>((resolve, reject) => {
|
|
115
|
+
/** @author ddj 2026年10月20号 Initializes a still-owned queue item. */
|
|
116
|
+
const run = async (): Promise<void> => {
|
|
117
|
+
try { await this.start(entry); resolve() } catch (error) { reject(error) }
|
|
118
|
+
}
|
|
119
|
+
this.queue.push(run)
|
|
120
|
+
this.pump()
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @private @author ddj 2026年10月20号 Advances FIFO work; each task holds its slot until startup or cleanup quiesces. */
|
|
125
|
+
private pump(): void {
|
|
126
|
+
while (this.running < 2 && this.queue.length) {
|
|
127
|
+
const run = this.queue.shift()!
|
|
128
|
+
this.running += 1
|
|
129
|
+
const job = run().finally(() => {
|
|
130
|
+
this.running -= 1
|
|
131
|
+
this.jobs.delete(job)
|
|
132
|
+
this.pump()
|
|
133
|
+
})
|
|
134
|
+
this.jobs.add(job)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @private @author ddj 2026年10月20号 @param entry Connection to initialize. @returns Initialization completion, or failure after owned cleanup. */
|
|
139
|
+
private async start(entry: PoolEntry): Promise<void> {
|
|
140
|
+
if (!entry.live || this.stopped) throw new Error('MCP 启动已取消')
|
|
141
|
+
entry.phase = 'loading'
|
|
142
|
+
entry.phaseAt = Date.now()
|
|
143
|
+
entry.startAt = entry.phaseAt
|
|
144
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
145
|
+
const work = this.mount(entry)
|
|
146
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
147
|
+
timer = setTimeout(() => reject(new Error('MCP 初始化超时')), entry.spec.policy.startupTimeoutMs)
|
|
148
|
+
timer.unref?.()
|
|
149
|
+
})
|
|
150
|
+
try {
|
|
151
|
+
await Promise.race([work, deadline])
|
|
152
|
+
if (!entry.live) throw new Error('MCP 启动已取消')
|
|
153
|
+
entry.phase = 'ready'
|
|
154
|
+
entry.finishedAt = Date.now()
|
|
155
|
+
entry.connectedAt = entry.finishedAt
|
|
156
|
+
} catch (error) {
|
|
157
|
+
entry.error = 'MCP 初始化失败或超时,请查看 Host 日志'
|
|
158
|
+
this.ctx.logger?.warn?.('MCP initialization failed: ' + String(error))
|
|
159
|
+
// Invalidation precedes disposal; neither late module load nor callbacks may publish.
|
|
160
|
+
entry.live = false
|
|
161
|
+
entry.phase = 'stopping'
|
|
162
|
+
const closing = this.closeBridge(entry)
|
|
163
|
+
await Promise.allSettled([closing, work])
|
|
164
|
+
entry.phase = 'error'
|
|
165
|
+
entry.finishedAt = Date.now()
|
|
166
|
+
entry.retryAt = Date.now() + 5000
|
|
167
|
+
// Failed generations stay invalid; acquire can replace them only after confirmed closure and cooldown.
|
|
168
|
+
throw new Error(entry.error)
|
|
169
|
+
} finally {
|
|
170
|
+
if (timer) clearTimeout(timer)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @private @author ddj 2026年10月20号 @param entry Owned generation. @returns Official initial sync; never publishes after invalidation. */
|
|
175
|
+
private async mount(entry: PoolEntry): Promise<void> {
|
|
176
|
+
const official = await this.load()
|
|
177
|
+
if (!entry.live || this.stopped) return
|
|
178
|
+
const bridge = await this.make()
|
|
179
|
+
if (!entry.live || this.stopped) { await bridge.dispose(); return }
|
|
180
|
+
entry.bridge = bridge
|
|
181
|
+
entry.phase = 'starting'
|
|
182
|
+
entry.phaseAt = Date.now()
|
|
183
|
+
entry.fiber = bridge.scope.ctx.plugin(official, entry.spec.config)
|
|
184
|
+
await entry.fiber
|
|
185
|
+
if (entry.fiber?.state !== undefined && entry.fiber.state !== 2) throw new Error('MCP 插件未完成激活')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @private @author ddj 2026年10月20号 @param entry Connection to close. @returns Shared closure barrier; rejection retains a fenced entry. */
|
|
189
|
+
private async closeBridge(entry: PoolEntry): Promise<void> {
|
|
190
|
+
if (entry.close) return entry.close
|
|
191
|
+
const close = entry.bridge?.dispose() ?? Promise.resolve()
|
|
192
|
+
entry.close = close
|
|
193
|
+
try {
|
|
194
|
+
await close
|
|
195
|
+
entry.bridge = undefined
|
|
196
|
+
entry.fiber = undefined
|
|
197
|
+
entry.close = undefined
|
|
198
|
+
} catch (error) {
|
|
199
|
+
entry.error = 'MCP 释放未确认,禁止重建,请查看 Host 日志'
|
|
200
|
+
this.ctx.logger?.warn?.('MCP cleanup failed: ' + String(error))
|
|
201
|
+
throw error
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @private @author ddj 2026年10月20号 @param entry One lease's owner. Arms workspace-only warm retention. */
|
|
206
|
+
private release(entry: PoolEntry): void {
|
|
207
|
+
entry.refs = Math.max(0, entry.refs - 1)
|
|
208
|
+
if (entry.refs || !entry.live) return
|
|
209
|
+
if (entry.spec.policy.sharing === 'agent') { void this.retire(entry).catch(() => undefined); return }
|
|
210
|
+
entry.idle = setTimeout(() => { void this.retire(entry).catch(() => undefined) }, 60000)
|
|
211
|
+
entry.idle.unref?.()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @public @author ddj 2026年10月20号 @param entry Invalid generation. @returns Quiescent close; callers may await before starting a replacement. */
|
|
215
|
+
async retire(entry: PoolEntry): Promise<void> {
|
|
216
|
+
entry.live = false
|
|
217
|
+
entry.phase = 'stopping'
|
|
218
|
+
if (entry.idle) clearTimeout(entry.idle)
|
|
219
|
+
await this.closeBridge(entry)
|
|
220
|
+
await entry.ready.catch(() => undefined)
|
|
221
|
+
// A late mount checks live before assigning its bridge.
|
|
222
|
+
await entry.calls.catch(() => undefined)
|
|
223
|
+
if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @public @author ddj 2026年10月20号 @param entry Authorized connection. @param action Per-call work retaining original execution identity. @param signal Caller cancellation. @returns Serialized shared result; no direct registry bypass is introduced here. */
|
|
227
|
+
run<T>(entry: PoolEntry, action: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
228
|
+
/** @author ddj 2026年10月20号 Checks cancellation and generation again at actual dispatch. */
|
|
229
|
+
const invoke = async (): Promise<T> => {
|
|
230
|
+
await waitMcp(entry.ready, signal)
|
|
231
|
+
if (signal?.aborted || !entry.live || entry.phase !== 'ready') throw new Error('MCP 连接不可用或调用已取消')
|
|
232
|
+
return action()
|
|
233
|
+
}
|
|
234
|
+
const result = entry.calls.then(invoke)
|
|
235
|
+
entry.calls = result.catch(() => undefined)
|
|
236
|
+
return result
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @public @author ddj 2026年10月20号 @returns Full disposal of entries, queue, timers and owned work. */
|
|
240
|
+
async dispose(): Promise<void> {
|
|
241
|
+
this.stopped = true
|
|
242
|
+
await Promise.all([...this.entries.values()].map((entry) => this.retire(entry)))
|
|
243
|
+
await Promise.all([...this.jobs])
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/mcpProject.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { PROJECT_ENTRY_PREFIX, entriesOf, mergeMcp, validateConfig, nameOverlap,
|
|
|
5
5
|
import { isProjectEntryId } from './compat.js'
|
|
6
6
|
import { clearLegacy, runtimeOf } from './mcpRuntime.js'
|
|
7
7
|
import type { ProjectDef } from './mcpRuntime.js'
|
|
8
|
-
import type
|
|
8
|
+
import { parseMcpPolicy, type McpPolicy, type MpcConfig, type MpcProject, type MpcServer } from './shared/mcp.js'
|
|
9
9
|
import type { Ctx } from './store.js'
|
|
10
10
|
|
|
11
11
|
const FILE_NAME = '.mcp.json'
|
|
@@ -192,19 +192,20 @@ export async function checkMcpName(ctx: Ctx, serverName: string, workspacePath?:
|
|
|
192
192
|
* @param ctx Host services.
|
|
193
193
|
* @param workspacePath Registered workspace.
|
|
194
194
|
* @param refresh Optional unchanged server to reconnect.
|
|
195
|
-
* @
|
|
195
|
+
* @param wait Await connection synchronization for management, or only publication/scheduling for creation.
|
|
196
|
+
* @returns Configuration publication, optionally including scoped fiber synchronization.
|
|
196
197
|
*/
|
|
197
|
-
export async function reconcileProject(ctx: Ctx, workspacePath: string, refresh?: string): Promise<void> {
|
|
198
|
+
export async function reconcileProject(ctx: Ctx, workspacePath: string, refresh?: string, wait = true): Promise<void> {
|
|
198
199
|
const data = await readProjectJson(ctx, workspacePath)
|
|
199
200
|
const defs = new Map<string, ProjectDef>()
|
|
200
201
|
for (const [name, def] of Object.entries(serversOf(data))) {
|
|
201
202
|
const config = configFromDef(def, name)!
|
|
202
203
|
validateConfig(config)
|
|
203
204
|
await checkMcpName(ctx, name, workspacePath)
|
|
204
|
-
defs.set(name, { config, enabled: def.disabled !== true })
|
|
205
|
+
defs.set(name, { config, enabled: def.disabled !== true, policy: parseMcpPolicy(def.vscodeMode) })
|
|
205
206
|
}
|
|
206
207
|
await clearLegacy(ctx)
|
|
207
|
-
await runtimeOf(ctx).replace(workspacePath, defs, refresh)
|
|
208
|
+
await runtimeOf(ctx).replace(workspacePath, defs, refresh, wait)
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
/**
|
|
@@ -245,7 +246,7 @@ export async function deactivateMcp(ctx: Ctx, entryId: string): Promise<void> {
|
|
|
245
246
|
* @param title Workspace title.
|
|
246
247
|
* @returns Configuration and honest runtime status with any read error attached.
|
|
247
248
|
*/
|
|
248
|
-
async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promise<MpcProject> {
|
|
249
|
+
async function projectOf(ctx: Ctx, workspacePath: string, title: string, wait = true): Promise<MpcProject> {
|
|
249
250
|
let fileError: string | undefined
|
|
250
251
|
let missingDir: boolean | undefined
|
|
251
252
|
try {
|
|
@@ -253,7 +254,7 @@ async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promis
|
|
|
253
254
|
if (!fs) throw new Error('缺少 fs 服务')
|
|
254
255
|
const info = await fs.stat(await fs.resolve(workspacePath))
|
|
255
256
|
missingDir = !info || info.type !== 'directory'
|
|
256
|
-
if (!missingDir) await reconcileProject(ctx, workspacePath)
|
|
257
|
+
if (!missingDir) await reconcileProject(ctx, workspacePath, undefined, wait)
|
|
257
258
|
} catch (error) {
|
|
258
259
|
missingDir = isMissingFile(error) || undefined
|
|
259
260
|
fileError = String(error)
|
|
@@ -271,9 +272,9 @@ async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promis
|
|
|
271
272
|
* @param ctx Host services.
|
|
272
273
|
* @returns Project list; individual malformed files are reported on their project.
|
|
273
274
|
*/
|
|
274
|
-
export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }> {
|
|
275
|
+
export async function listProjects(ctx: Ctx, wait = true): Promise<{ projects: MpcProject[] }> {
|
|
275
276
|
const projects: MpcProject[] = []
|
|
276
|
-
for (const ws of ctx.get('workspaceRegistry')?.list?.() ?? []) projects.push(await projectOf(ctx, ws.path, ws.title ?? ''))
|
|
277
|
+
for (const ws of ctx.get('workspaceRegistry')?.list?.() ?? []) projects.push(await projectOf(ctx, ws.path, ws.title ?? '', wait))
|
|
277
278
|
return { projects }
|
|
278
279
|
}
|
|
279
280
|
|
|
@@ -316,18 +317,20 @@ function mergeDefinition(previous: Record<string, unknown>, config: MpcConfig):
|
|
|
316
317
|
* @param config Submitted configuration.
|
|
317
318
|
* @returns Updated project view; invalid sources or conflicts reject before writing.
|
|
318
319
|
*/
|
|
319
|
-
export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
|
|
320
|
-
|
|
320
|
+
export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig, policy?: McpPolicy): Promise<MpcProject> {
|
|
321
|
+
if (policy !== undefined) parseMcpPolicy(policy)
|
|
322
|
+
return mcpEdit(ctx, () => saveProject(ctx, workspacePath, serverName, config, policy))
|
|
321
323
|
}
|
|
322
324
|
|
|
323
325
|
/** @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> {
|
|
326
|
+
async function saveProject(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig, policy?: McpPolicy): Promise<MpcProject> {
|
|
325
327
|
const workspace = requireWorkspace(ctx, workspacePath)
|
|
326
328
|
validateConfig({ ...config, serverName })
|
|
327
329
|
await checkMcpName(ctx, serverName, workspacePath)
|
|
328
330
|
const data = await readProjectJson(ctx, workspacePath)
|
|
329
331
|
const servers = serversOf(data)
|
|
330
332
|
servers[serverName] = mergeDefinition(servers[serverName] ?? {}, { ...config, serverName })
|
|
333
|
+
if (policy !== undefined) servers[serverName].vscodeMode = { ...parseMcpPolicy(policy) }
|
|
331
334
|
await writeProjectJson(ctx, workspacePath, { ...data, mcpServers: servers })
|
|
332
335
|
await reconcileProject(ctx, workspacePath)
|
|
333
336
|
return projectOf(ctx, workspacePath, workspace.title ?? '')
|