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.
@@ -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 { MpcConfig, MpcProject, MpcServer } from './shared/mcp.js'
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
- * @returns Completion after scoped fibers match the file, including disabled flags.
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
- return mcpEdit(ctx, () => saveProject(ctx, workspacePath, serverName, config))
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 ?? '')