weir-harness 2.0.0 → 2.1.1

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.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +40 -9
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +170 -57
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -1,28 +1,22 @@
1
- // Task 8.2/8.4/8.5/8.8/8.9 of the session-capability-manager change: the MCP
2
- // manager row. It owns the registry-backed runtime mounts, the shared
3
- // (agent, server) drain, the realm-visible gate the facades consume, the
4
- // creation-time schema hiding, the managed/unmanaged listing and the
5
- // explicit adopt flow. Host-configured, ACP-mounted and other presets'
6
- // servers are UNMANAGED: listed, never given a close switch, never counted
7
- // as closed (8.8).
1
+ // Registry-backed managed MCP runtime. Startup and targeted add/edit/restart/
2
+ // remove/probe share reconcileOne(); siblings are never used as replacement
3
+ // collateral. Status joins only prove registry/runtime/mount/readback facts.
8
4
  import { openCapabilityStore } from './store/store.js'
9
5
  import { createMcpRegistry } from './mcp-registry.js'
10
6
  import { createMcpGate } from './mcp-gate.js'
11
7
  import { createMcpDrain } from './mcp-drain.js'
12
- import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX } from './mcp-mount.js'
8
+ import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX, resolveEphemeralMcpTree } from './mcp-mount.js'
13
9
  import { registerMcpFacadeBridge } from './mcp-facade-plugin.js'
14
10
  import { skillSelectionFor } from './skill-selection-plugin.js'
11
+ import { createMcpRuntime, createMcpIdentityLock } from './mcp-runtime.js'
12
+ import { materializeMcpClientConfig } from './mcp-credentials.js'
13
+ import { hasMcpSecrets, redactMcpEntry, fingerprintMcpEntry } from './mcp-config.js'
14
+ import { createMcpProcessAdapter } from './mcp-process.js'
15
+ import { mcpDiagnostic, sanitizeMcpText } from './mcp-diagnostics.js'
15
16
 
16
17
  const message = error => (error instanceof Error ? error.message : String(error))
18
+ const now = () => Date.now()
17
19
 
18
- /**
19
- * Managed vs unmanaged rows in a flat entry listing (8.8). A row is managed
20
- * when its group id carries our prefix; stock dsh-mcp-client rows anywhere
21
- * else are unmanaged.
22
- * @param {Array<{ id?: string, name?: string, config?: { serverName?: string } }>} rows
23
- * @param {Map<string, { groupId: string, generation: number }>} mounted
24
- * @param {Record<string, { label?: string, generation: number }>} registryServers
25
- */
26
20
  export function partitionMcpRows(rows, mounted, registryServers) {
27
21
  const managed = []
28
22
  const unmanaged = []
@@ -32,21 +26,12 @@ export function partitionMcpRows(rows, mounted, registryServers) {
32
26
  const registry = registryServers[identity]
33
27
  managed.push({ identity, groupId: row.id, label: registry?.label ?? identity, generation: mounted.get(identity)?.generation ?? registry?.generation ?? null, state: mounted.has(identity) ? 'mounted' : 'registered' })
34
28
  } else if (row?.name === '@deepseek-ai/dsh-mcp-client' && !(typeof row.id === 'string' && row.id.startsWith(MCP_GROUP_PREFIX))) {
35
- // Stock client rows OUTSIDE our groups: host-configured, ACP-mounted
36
- // or other presets' servers — listed, never closable (8.8).
37
- unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged' })
29
+ unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged', readOnly: true })
38
30
  }
39
31
  }
40
32
  return { managed, unmanaged }
41
33
  }
42
34
 
43
- /**
44
- * The adopt-conflict decision (8.9): a same-named host row holds the
45
- * first-come serverName reservation, so mounting is ineffective until the
46
- * user disables it (1.14 S5a/S5b) — surface a conflict, never a success.
47
- * @param {Array<{ id?: string, name?: string, config?: { serverName?: string } }>} rows
48
- * @param {string} serverName
49
- */
50
35
  export function hostRowHoldingReservation(rows, serverName) {
51
36
  return rows.find(row => row?.name === '@deepseek-ai/dsh-mcp-client' && row.config?.serverName === serverName
52
37
  && !(typeof row.id === 'string' && row.id.startsWith(MCP_GROUP_PREFIX))) ?? null
@@ -57,103 +42,426 @@ export function createMcpManager(dependencies = {}) {
57
42
  const profileContext = ctx.get?.('profileContext')
58
43
  const store = dependencies.store ?? openCapabilityStore({ profileContext })
59
44
  const registry = dependencies.registry ?? createMcpRegistry({ store })
60
- // The selection face is resolved LAZILY on every gate verdict: mount
61
- // order inside one apply batch is not contractual (sibling rows import
62
- // concurrently), so a face captured here can be a permanently-null
63
- // fallback that refuses every managed call. Late arrival must converge
64
- // exactly like the intent-gate route override (rc.2 lesson).
65
45
  const lifecycle = dependencies.lifecycle ?? {
66
46
  snapshotFor: sessionId => skillSelectionFor(ctx)?.lifecycle?.snapshotFor(sessionId) ?? null,
67
47
  }
68
- const gate = dependencies.gate ?? createMcpGate({ registry, lifecycle })
48
+ const runtime = dependencies.runtime ?? createMcpRuntime()
49
+ // Every runtime.begin path runs inside this per-identity queue (design D6).
50
+ const serialize = createMcpIdentityLock()
69
51
  const drain = dependencies.drain ?? createMcpDrain({ timeoutMs: config.drainTimeoutMs ?? 30_000 })
70
- // The loader store is the flat id → entry map of every activated row
71
- // (CITED 1.14 manager usage); options carry each row's name/config.
72
- // Loader extends EntryTree (CITED): entries() walks static rows AND
73
- // nested subtrees; the store holds runtime-created entries only.
74
- const defaultListRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({
75
- id: entry?.options?.id ?? null,
76
- name: entry?.options?.name ?? null,
77
- config: entry?.options?.config ?? {},
78
- }))
52
+ const gate = dependencies.gate ?? createMcpGate({ registry, lifecycle, runtime, registrationFor: runtime.registrationFor })
53
+ const loader = dependencies.loader ?? ctx.get?.('loader')
54
+ const defaultListRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({ id: entry?.options?.id ?? entry?.id ?? null, name: entry?.options?.name ?? null, config: entry?.options?.config ?? {} }))
79
55
  const listEntryIds = dependencies.listEntryIds ?? (() => defaultListRows().map(row => row.id).filter(Boolean))
80
56
  const listRows = dependencies.listRows ?? defaultListRows
81
- const onError = (reason, cause) => ctx.logger?.warn?.(`${reason}${cause ? `: ${message(cause)}` : ''}`)
82
-
83
- const loader = dependencies.loader ?? ctx.get?.('loader')
84
- // Readiness by the child client's fiber after loader.await() (1.14
85
- // must-fix): a group child resolves as '<group>-client' in the tree.
57
+ const startErrors = []
58
+ /** A strict mount of this identity supersedes its earlier start/mount failures. */
59
+ const clearStartErrors = identity => {
60
+ for (let index = startErrors.length - 1; index >= 0; index -= 1) if (startErrors[index].identity === identity) startErrors.splice(index, 1)
61
+ }
62
+ const onError = (reason, cause) => ctx.logger?.warn?.(sanitizeMcpText(`${reason}${cause ? `: ${message(cause)}` : ''}`))
86
63
  const defaultFiberOf = async groupId => {
87
- // Readiness the 1.14 way: resolve the client entry, then AWAIT its own
88
- // fiber lifecycle — a group child whose connection never comes up or
89
- // whose activation errored surfaces HERE, not as a blind success.
90
64
  for (const candidate of [`${groupId}:${groupId}-client`, `${groupId}-client`]) {
91
65
  let entry = null
92
- try {
93
- entry = loader?.resolve?.(candidate)
94
- } catch { /* try the next shape */ }
66
+ try { entry = loader?.resolve?.(candidate) } catch { /* next shape */ }
95
67
  if (entry?.fiber) {
96
- try {
97
- if (typeof entry.fiber.await === 'function') await entry.fiber.await()
98
- return { state: 'running' }
99
- } catch (cause) {
100
- startErrors.push({ identity: null, reason: `fiber-await-failed:${groupId}:${message(cause)}` })
101
- return null
102
- }
68
+ await entry.fiber.await()
69
+ return { state: 'running' }
103
70
  }
104
71
  }
105
72
  return null
106
73
  }
107
- const mount = dependencies.mount ?? createMcpMount({ loader, onError })
74
+ const mount = dependencies.mount ?? createMcpMount({ loader, onError, startupTimeoutMs: config.startupTimeoutMs ?? 15_000, cleanupTimeoutMs: config.cleanupTimeoutMs ?? 15_000 })
108
75
  const fiberOf = dependencies.fiberOf ?? defaultFiberOf
109
- /** publicName → identity, fed by facade registrations (creation-time schema hiding + managed listing). */
110
- const publicNames = new Map()
111
-
76
+ const processAdapter = dependencies.processAdapter ?? createMcpProcessAdapter({ subprocess: dependencies.subprocess ?? ctx.get?.('subprocess') })
77
+ const credentialsFor = () => dependencies.credentials ?? ctx.get?.('credentials')
78
+ const support = () => {
79
+ const credentials = credentialsFor()
80
+ return { credentials: typeof credentials?.set === 'function' && typeof credentials?.resolve === 'function', ephemeral: resolveEphemeralMcpTree(loader).kind === 'ok' }
81
+ }
82
+ const publicNames = new Map() // publicName → { identity, generation }
83
+ const bridges = new Map() // groupId → { identity, generation, release }
84
+ const receipts = { mount: new Map(), test: new Map() }
112
85
  const host = {
113
86
  tools: dependencies.hostTools ?? ctx.get?.('tools'),
114
87
  systemPrompt: dependencies.hostSystemPrompt ?? ctx.get?.('systemPrompt'),
115
88
  mcpResources: dependencies.hostMcpResources ?? ctx.get?.('mcpResources'),
116
89
  }
90
+ const agentsService = dependencies.agents ?? ctx.get?.('agents')
91
+ const seenAgents = new Set() // compatibility fallback; actual agent objects, never session ids
92
+ const restrictions = new Map() // agent → Map(publicName → { owner, dispose })
117
93
 
118
- /** The realm-visible gate face (8.2): facades consume it from inside their groups. */
119
- const gateFace = {
120
- admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
121
- enabledFor: (agent, identity) => gate.enabledFor(agent, identity),
122
- drain,
94
+ const identityOf = owner => typeof owner === 'string' ? owner : owner?.identity
95
+ const sameOwner = (owner, identity, generation) => typeof owner === 'string'
96
+ ? owner === identity // legacy test facts carried no generation
97
+ : owner?.identity === identity && owner?.generation === generation
98
+ const isAgent = agent => (typeof agent === 'object' && agent !== null) || typeof agent === 'function'
99
+ function rememberAgent(agent) {
100
+ if (!isAgent(agent)) return null
101
+ seenAgents.add(agent)
102
+ return agent
103
+ }
104
+ function liveAgents() {
105
+ if (typeof agentsService?.list !== 'function') return [...seenAgents]
106
+ const listed = agentsService.list()
107
+ if (!Array.isArray(listed)) throw new TypeError('agents.list() must return an array')
108
+ for (const agent of listed) rememberAgent(agent)
109
+ return listed
110
+ }
111
+
112
+ /**
113
+ * Reconcile only actual differences. Every held entry owns exactly one
114
+ * independent deny disposer; absent public names never reach restrict().
115
+ */
116
+ function reconcileAgent(agent, matches = () => true, added = null) {
117
+ if (!isAgent(agent)) return
118
+ let held = restrictions.get(agent)
119
+ const names = new Set()
120
+ for (const [name, owner] of publicNames) if (matches(name, owner)) names.add(name)
121
+ for (const [name, record] of held ?? []) if (matches(name, record.owner)) names.add(name)
122
+ let failure = null
123
+ for (const name of names) {
124
+ try {
125
+ const present = publicNames.has(name)
126
+ const owner = publicNames.get(name)
127
+ const existing = held?.get(name)
128
+ const shouldDeny = present && !gate.enabledFor(agent, identityOf(owner))
129
+ if (shouldDeny && !existing) {
130
+ const restrict = agent?.ctx?.tools?.restrict
131
+ if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
132
+ const disposer = restrict.call(agent.ctx.tools, { deny: [name] })
133
+ if (!held) {
134
+ held = new Map()
135
+ restrictions.set(agent, held)
136
+ }
137
+ const record = { owner, dispose: typeof disposer === 'function' ? disposer : null }
138
+ held.set(name, record)
139
+ added?.push({ agent, name, record })
140
+ } else if (!shouldDeny && existing) {
141
+ existing.dispose?.()
142
+ held.delete(name)
143
+ } else if (shouldDeny && existing) {
144
+ // A replacement may reuse the public name. Keep the exact deny and
145
+ // update only its ownership metadata; never dispose/recreate it.
146
+ existing.owner = owner
147
+ }
148
+ } catch (cause) {
149
+ failure ??= cause
150
+ }
151
+ }
152
+ if (held?.size === 0) restrictions.delete(agent)
153
+ if (failure) throw failure
123
154
  }
124
- try { ctx.reflect?.provide?.('weirMcpGate', gateFace) } catch { /* an older row already provides it */ }
125
155
 
126
- async function mountOne(identity, entry) {
127
- const generation = entry.generation
128
- const groupId = `${MCP_GROUP_PREFIX}${identity}`
156
+ function reconcileAgents(matches, added = null) {
157
+ let failure = null
158
+ for (const agent of liveAgents()) {
159
+ try { reconcileAgent(agent, matches, added) } catch (cause) { failure ??= cause }
160
+ }
161
+ if (failure) throw failure
162
+ }
163
+ const syncName = (name, added = null) => reconcileAgents(candidate => candidate === name, added)
164
+ const syncIdentity = identity => reconcileAgents((_name, owner) => identityOf(owner) === identity)
165
+
166
+ function syncSession(sessionId) {
167
+ let agent
168
+ if (typeof agentsService?.get === 'function') agent = agentsService.get(sessionId)
169
+ else agent = [...seenAgents].find(candidate => candidate?.session?.id === sessionId)
170
+ if (rememberAgent(agent)) reconcileAgent(agent)
171
+ }
172
+
173
+ function registerPublicName(name, identity, generation, captureRollback) {
174
+ const hadPrevious = publicNames.has(name)
175
+ const previous = publicNames.get(name)
176
+ const attempted = { identity, generation }
177
+ const previousDenies = []
178
+ for (const [agent, held] of restrictions) {
179
+ const record = held.get(name)
180
+ if (record) previousDenies.push({ agent, record, owner: record.owner })
181
+ }
182
+ const added = []
183
+ publicNames.set(name, attempted)
184
+ captureRollback?.(() => {
185
+ // Object identity distinguishes attempts even within one generation.
186
+ // A newer owner must never be overwritten by a stale Host rollback.
187
+ if (publicNames.get(name) !== attempted) return
188
+ let failure = null
189
+ const fail = cause => {
190
+ failure ??= cause
191
+ onError(`mcp-schema-rollback-failed:${name}`, cause)
192
+ }
193
+ if (hadPrevious) publicNames.set(name, previous)
194
+ else publicNames.delete(name)
195
+
196
+ // Recreate denies removed by the failed sync before reusing surviving
197
+ // records, then dispose only denies introduced by this attempt.
198
+ for (const { agent, owner } of previousDenies) {
199
+ if (restrictions.get(agent)?.has(name)) continue
200
+ try {
201
+ const restrict = agent?.ctx?.tools?.restrict
202
+ if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
203
+ const disposer = restrict.call(agent.ctx.tools, { deny: [name] })
204
+ let held = restrictions.get(agent)
205
+ if (!held) {
206
+ held = new Map()
207
+ restrictions.set(agent, held)
208
+ }
209
+ held.set(name, { owner, dispose: typeof disposer === 'function' ? disposer : null })
210
+ } catch (cause) { fail(cause) }
211
+ }
212
+ for (const { agent, record, owner } of previousDenies) {
213
+ if (restrictions.get(agent)?.get(name) === record) record.owner = owner
214
+ }
215
+ for (const { agent, record } of added.reverse()) {
216
+ const held = restrictions.get(agent)
217
+ if (held?.get(name) !== record) continue
218
+ try {
219
+ record.dispose?.()
220
+ if (held.get(name) === record) held.delete(name)
221
+ if (held.size === 0) restrictions.delete(agent)
222
+ } catch (cause) { fail(cause) }
223
+ }
224
+ if (failure) throw failure
225
+ })
226
+ // Until confirmed Host disposal, retain the attempted owner and every
227
+ // partial deny. The facade invokes only this transaction's rollback.
228
+ syncName(name, added)
229
+ }
230
+
231
+ function unregisterPublicName(name, identity, generation) {
232
+ if (!sameOwner(publicNames.get(name), identity, generation)) return false
233
+ publicNames.delete(name)
234
+ try { syncName(name) } catch (cause) { onError(`mcp-schema-cleanup-failed:${name}`, cause) }
235
+ return true
236
+ }
237
+
238
+ function disposeAgentRestrictions(agent, { onlyAbsent = false } = {}) {
239
+ const held = restrictions.get(agent)
240
+ if (!held) return
241
+ for (const [name, record] of held) {
242
+ if (onlyAbsent && publicNames.has(name)) continue
243
+ try {
244
+ record.dispose?.()
245
+ held.delete(name)
246
+ } catch (cause) {
247
+ // Cleanup is fail-closed: an unconfirmed disposer stays owned here.
248
+ onError(`mcp-schema-dispose-failed:${name}`, cause)
249
+ }
250
+ }
251
+ if (held.size === 0) restrictions.delete(agent)
252
+ }
253
+
254
+ // Agents that predate this manager are authoritative through the Host
255
+ // registry. No public names exist yet, so bootstrap only records identity.
256
+ if (typeof agentsService?.list === 'function') {
257
+ try { liveAgents() } catch (cause) { onError('mcp-agent-bootstrap-failed', cause) }
258
+ }
259
+
260
+ function releaseBridge(groupId, expected = null) {
261
+ const bridge = bridges.get(groupId)
262
+ if (!bridge) return
263
+ if (expected && (bridge.identity !== expected.identity || bridge.generation !== expected.generation)) return
264
+ bridges.delete(groupId)
265
+ for (const [name, owner] of [...publicNames]) {
266
+ if (sameOwner(owner, bridge.identity, bridge.generation)) unregisterPublicName(name, bridge.identity, bridge.generation)
267
+ }
268
+ bridge.release()
269
+ }
270
+ function registerBridge(groupId, identity, generation) {
129
271
  const release = registerMcpFacadeBridge(groupId, {
130
- host,
131
- identity,
132
- generation,
133
- admit: agent => gate.admit(agent, identity, generation),
272
+ host, identity, generation,
273
+ admit: agent => gate.permit(agent, identity, generation),
274
+ assertPermit: gate.assertPermit,
134
275
  isEnabledFor: agent => gate.enabledFor(agent, identity),
135
276
  drain,
136
- onRegister: name => publicNames.set(name, identity),
277
+ onRegister: (name, captureRollback) => registerPublicName(name, identity, generation, captureRollback),
278
+ onUnregister: name => unregisterPublicName(name, identity, generation),
137
279
  onError,
138
280
  })
281
+ bridges.set(groupId, { identity, generation, release })
282
+ }
283
+ function discoveryFor(identity, generation) {
284
+ const names = [...publicNames.entries()].filter(([, owner]) => owner.identity === identity && owner.generation === generation).map(([name]) => name).sort()
285
+ return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
286
+ }
287
+
288
+ /** Every manager-owned runtime mutation converges visibility after the actual state change. */
289
+ function transitionRuntime(identity, change) {
290
+ const before = runtime.snapshot(identity)
291
+ const result = change()
292
+ const after = runtime.snapshot(identity)
293
+ const changed = before.phase !== after.phase || before.generation !== after.generation
294
+ || (before.incarnation ?? null) !== (after.incarnation ?? null) || before.operationId !== after.operationId
295
+ if (changed) {
296
+ try { syncIdentity(identity) } catch (cause) { onError(`mcp-visibility-sync-failed:${identity}`, cause) }
297
+ }
298
+ return result
299
+ }
300
+ function finishOperation(token, phase, details = {}) {
301
+ return transitionRuntime(token.identity, () => runtime.finish(token, phase, details))
302
+ }
303
+ function beginOperation(identity, phase = 'replacing', details = {}) {
304
+ return transitionRuntime(identity, () => {
305
+ const token = runtime.begin(identity, { phase, ...details })
306
+ drain.closeAll(identity)
307
+ return token
308
+ })
309
+ }
310
+ function abortOperation(token) {
311
+ const previousPhase = token.previous?.phase
312
+ return transitionRuntime(token.identity, () => {
313
+ const restored = runtime.restore(token)
314
+ if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
315
+ return restored
316
+ })
317
+ }
318
+ async function waitForDrain(identity) {
319
+ const result = await drain.drainAll(identity)
320
+ if (!result.drained) {
321
+ const error = new Error(`MCP server "${identity}" still has ${result.inFlight} admitted call(s); replacement cannot prove quiescence`)
322
+ error.code = 'cleanup-unknown'
323
+ throw error
324
+ }
325
+ }
326
+
327
+ async function prepare(entry) {
328
+ const credentials = credentialsFor()
329
+ const secretBearing = hasMcpSecrets(entry)
330
+ if (Object.keys(entry?.secretRefs ?? {}).length > 0 && typeof credentials?.resolve !== 'function') {
331
+ const error = new Error('secret-bearing MCP materialization requires DSH credentials')
332
+ error.code = 'unsupported'
333
+ throw error
334
+ }
335
+ const materialized = await materializeMcpClientConfig(entry, credentials)
336
+ const prepared = await processAdapter.prepare(materialized.client)
337
+ return { ...prepared, secrets: materialized.resolvedSecrets, secretBearing }
338
+ }
339
+
340
+ /** Resolve only non-secret execution metadata for an exact confirmation card. */
341
+ async function describeExecution(entry) {
342
+ if (entry?.client?.transport !== 'stdio') return null
343
+ const secretArgs = new Set(Object.keys(entry?.secretRefs?.args ?? {}).map(Number))
344
+ const client = {
345
+ ...entry.client,
346
+ args: (entry.client.args ?? []).map((value, index) => secretArgs.has(index) ? '' : value),
347
+ env: {},
348
+ }
349
+ return (await processAdapter.prepare(client)).execution
350
+ }
351
+
352
+ async function reconcileOne(identity, entry, suppliedToken = null) {
353
+ const token = suppliedToken ?? beginOperation(identity)
139
354
  try {
140
- await mount.mount({ identity, generation, client: entry.client ?? entry.transport, fiberOf })
141
- // A fiber is not health: the stock client may carry an activation
142
- // error (bad import, failed connect). Surface it like the 1.14
143
- // entryState check instead of reporting a blind success.
144
- const clientEntry = loader?.store?.[`${groupId}-client`]
145
- if (clientEntry?.error) {
146
- throw new Error(`mcp client entry error for "${identity}": ${message(clientEntry.error)}`)
355
+ const current = await registry.read()
356
+ const registered = current.kind === 'ok' ? current.servers[identity] : null
357
+ if (!registered || registered.generation !== entry.generation || (registered.incarnation ?? null) !== (entry.incarnation ?? null)) {
358
+ const error = new Error(`MCP registry/mount readback rejected ${identity} generation ${entry.generation}`)
359
+ error.code = 'generation-readback-failed'
360
+ throw error
147
361
  }
362
+ await waitForDrain(identity)
363
+ if (mount.mounted.has(identity)) {
364
+ const removed = await mount.unmount(identity)
365
+ if (removed.status !== 'cleaned') {
366
+ finishOperation(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
367
+ return { status: removed.status, identity, cleanup: removed, saved: true }
368
+ }
369
+ releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
370
+ }
371
+ const prepared = await prepare(entry)
372
+ const groupId = `${MCP_GROUP_PREFIX}${identity}`
373
+ const result = await mount.mount({
374
+ identity,
375
+ generation: entry.generation,
376
+ client: prepared.client,
377
+ secrets: prepared.secrets,
378
+ secretBearing: prepared.secretBearing,
379
+ execution: prepared.execution,
380
+ fiberOf,
381
+ beforeCreate: () => registerBridge(groupId, identity, entry.generation),
382
+ afterCleanup: () => releaseBridge(groupId, { identity, generation: entry.generation }),
383
+ discovery: () => discoveryFor(identity, entry.generation),
384
+ readback: async () => {
385
+ const readback = await registry.read()
386
+ const actual = readback.kind === 'ok' ? readback.servers[identity] : null
387
+ if (!actual || actual.generation !== entry.generation || (actual.incarnation ?? null) !== (entry.incarnation ?? null)) throw new Error('registry generation/incarnation changed during mount')
388
+ },
389
+ })
390
+ const observed = mount.mounted.get(identity)
391
+ if (!observed || observed.generation !== entry.generation) throw new Error('mounted generation readback failed')
392
+ // Only the fence owner may report mounted and reopen admission: a
393
+ // superseded token means another operation now owns this identity.
394
+ if (!finishOperation(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
395
+ const diagnostic = mcpDiagnostic(new Error(`MCP operation on ${identity} was superseded before it could report mounted`), { code: 'operation-superseded', action: 'reread-registry' })
396
+ receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
397
+ return { status: 'indeterminate', identity, generation: entry.generation, incarnation: entry.incarnation ?? null, saved: true, error: diagnostic, mount: { state: 'superseded', observedGeneration: observed.generation }, next: { action: 'reread-registry' } }
398
+ }
399
+ drain.reopenAll(identity)
400
+ clearStartErrors(identity)
401
+ const receipt = { time: now(), status: 'mounted', generation: entry.generation, discovery: result.discovery }
402
+ receipts.mount.set(identity, receipt)
403
+ return { status: 'mounted', identity, generation: entry.generation, incarnation: entry.incarnation ?? null, mount: { state: 'mounted', observedGeneration: observed.generation }, discovery: result.discovery, execution: result.execution ?? null }
148
404
  } catch (cause) {
149
- release()
150
- throw cause
405
+ const cleanupStatus = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : null
406
+ const status = cleanupStatus ?? (cause?.code === 'unsupported' ? 'unsupported' : 'saved-degraded')
407
+ finishOperation(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
408
+ const diagnostic = mcpDiagnostic(cause, { code: cause?.code, action: cleanupStatus ? 'recover-residue' : 'retry' })
409
+ receipts.mount.set(identity, { time: now(), status, diagnostic })
410
+ startErrors.push({ identity, reason: diagnostic.message })
411
+ onError(`mcp-mount-${status}:${identity}`, cause)
412
+ return { status, identity, generation: entry.generation, incarnation: entry.incarnation ?? null, saved: true, error: diagnostic, mount: { state: cleanupStatus ?? 'degraded' }, next: { action: cleanupStatus ? 'recover-residue' : 'retry-or-edit' } }
151
413
  }
152
- return { groupId, release }
153
414
  }
154
415
 
155
- /** Startup: sweep residue, then mount every registered server. */
416
+ async function removeRuntime(identity, suppliedToken = null) {
417
+ const token = suppliedToken ?? beginOperation(identity, 'removing')
418
+ try {
419
+ await waitForDrain(identity)
420
+ const cleanup = await mount.unmount(identity)
421
+ if (cleanup.status !== 'cleaned') {
422
+ finishOperation(token, cleanup.status)
423
+ return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
424
+ }
425
+ releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
426
+ finishOperation(token, 'unknown', { generation: null, incarnation: null })
427
+ return { status: 'removed', identity, removed: true }
428
+ } catch (cause) {
429
+ const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
430
+ finishOperation(token, status)
431
+ return { status, identity, error: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }), next: { action: 'recover-residue' } }
432
+ }
433
+ }
434
+
435
+ async function probe(entry, { targetIdentity = entry.identity } = {}) {
436
+ try {
437
+ const prepared = await prepare(entry)
438
+ const result = await mount.probe({
439
+ identity: undefined,
440
+ generation: 0,
441
+ client: prepared.client,
442
+ secretBearing: prepared.secretBearing,
443
+ secrets: prepared.secrets,
444
+ fiberOf,
445
+ beforeCreate: ({ groupId, identity, generation }) => registerBridge(groupId, identity, generation),
446
+ afterCleanup: ({ groupId, identity, generation }) => releaseBridge(groupId, { identity, generation }),
447
+ discovery: ({ identity, generation }) => discoveryFor(identity, generation),
448
+ })
449
+ const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
450
+ // Receipts share one shape with lastMount: the row reads `diagnostic`.
451
+ receipts.test.set(targetIdentity, { time: now(), status: outcome.status, discovery: outcome.discovery, ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) })
452
+ return outcome
453
+ } catch (cause) {
454
+ const status = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : 'probe-failed'
455
+ const outcome = { status, identity: targetIdentity, error: mcpDiagnostic(cause, { code: cause?.code ?? status }), cleanup: cause?.cleanup }
456
+ receipts.test.set(targetIdentity, { time: now(), status, diagnostic: outcome.error })
457
+ return outcome
458
+ }
459
+ }
460
+
461
+ let started = false
156
462
  async function start() {
463
+ if (started) return { mounted: mount.mounted.size, alreadyStarted: true }
464
+ started = true
157
465
  const swept = await mount.sweepResidue(listEntryIds())
158
466
  const current = await registry.read()
159
467
  if (current.kind !== 'ok') {
@@ -167,88 +475,209 @@ export function createMcpManager(dependencies = {}) {
167
475
  }
168
476
  let mountedCount = 0
169
477
  for (const entry of Object.values(current.servers)) {
170
- try {
171
- await mountOne(entry.identity, entry)
172
- mountedCount += 1
173
- } catch (cause) {
174
- startErrors.push({ identity: entry.identity, reason: message(cause) })
175
- onError(`mcp-mount-failed:${entry.identity}`, cause)
176
- }
478
+ const result = await serialize(entry.identity, () => reconcileOne(entry.identity, entry))
479
+ if (result.status === 'mounted') mountedCount += 1
480
+ else if (result.status !== 'saved-degraded') startErrors.push({ identity: entry.identity, reason: result.status })
177
481
  }
178
482
  return { mounted: mountedCount }
483
+
484
+ }
485
+ const healthy = identity => runtime.snapshot(identity).phase === 'mounted' && mount.mounted.has(identity)
486
+ && (mount.mounted.get(identity)?.state ?? 'mounted') === 'mounted'
487
+
488
+ /** Recover one identity inside its queue slot: sweep untracked residue, then strictly remount. */
489
+ function recoverOne(identity, assertOwner) {
490
+ return serialize(identity, async () => {
491
+ // Re-read inside the slot: an earlier queued operation may have
492
+ // remounted, edited or removed this identity meanwhile.
493
+ const current = await registry.read()
494
+ if (assertOwner) await assertOwner('recover-cleanup')
495
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', identity, skipped: current.kind }
496
+ const entry = current.kind === 'ok' ? current.servers[identity] ?? null : null
497
+ if (entry && healthy(identity)) return { status: 'mounted', identity, generation: entry.generation, alreadyHealthy: true }
498
+ const groupId = `${MCP_GROUP_PREFIX}${identity}`
499
+ const residuePresent = listEntryIds().includes(groupId)
500
+ if (!entry) {
501
+ // Orphan residue of an identity that is no longer registered.
502
+ const swept = residuePresent ? await mount.sweepResidue([groupId]) : []
503
+ const cleaned = !residuePresent || swept.includes(groupId)
504
+ if (cleaned) releaseBridge(groupId)
505
+ return cleaned
506
+ ? { status: 'removed', identity, swept: residuePresent }
507
+ : { status: 'cleanup-failed', identity, error: mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' }), next: { action: 'recover-residue' } }
508
+ }
509
+ const token = beginOperation(identity)
510
+ // A tracked failed mount record is retried by reconcileOne's unmount; a
511
+ // Loader row the mount map no longer tracks is swept first, or the
512
+ // same-name replacement could never be created.
513
+ if (!mount.mounted.has(identity) && residuePresent) {
514
+ const swept = await mount.sweepResidue([groupId])
515
+ if (!swept.includes(groupId)) {
516
+ finishOperation(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
517
+ const diagnostic = mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
518
+ receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
519
+ return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
520
+ }
521
+ releaseBridge(groupId)
522
+ }
523
+ if (assertOwner) {
524
+ try { await assertOwner('mount') } catch (cause) {
525
+ // Cleanup may already have removed the old mount. Keep admission
526
+ // closed without stranding the queue fence in `replacing`.
527
+ finishOperation(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
528
+ throw cause
529
+ }
530
+ }
531
+ return reconcileOne(identity, entry, token)
532
+ })
179
533
  }
180
534
 
181
535
  /**
182
- * Explicit adopt flow (8.9): never an automatic takeover. The identity is
183
- * registered first; a same-named host row still holding the reservation
184
- * makes the outcome a visible conflict (the user disables the host row,
185
- * then the managed client mounts), never a claimed success.
536
+ * Residue recovery (user-triggered via the capabilities command verb
537
+ * mcp-recover, the mcp mutation action 'recover' and the panel's Recover
538
+ * row action). Healthy mounted identities are never touched; every other
539
+ * registered identity is strictly remounted and every orphan residue row is
540
+ * removed. `identity` scopes the work to one server. The optional owner
541
+ * callback runs inside every queue slot before cleanup and before remount.
186
542
  */
187
- async function adopt(entry) {
543
+ async function recoverResidue({ identity = null, assertOwner = null } = {}) {
188
544
  const current = await registry.read()
189
- const revision = current.kind === 'ok' ? current.revision : 0
190
- const registered = await registry.register(entry, revision)
191
- if (registered.status !== 'committed') return { status: 'registry-failed', result: registered.status }
192
- const holder = hostRowHoldingReservation(listRows(), entry.transport?.serverName ?? entry.identity)
193
- if (holder) {
194
- return {
195
- status: 'conflict',
196
- reason: `host row "${holder.id ?? entry.identity}" still holds the serverName reservation — disable it in the host configuration; the managed client mounts afterwards. Until then the server stays unmanaged.`,
545
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', skipped: current.kind, results: [] }
546
+ const servers = current.kind === 'ok' ? current.servers : {}
547
+ const orphans = listEntryIds().filter(isMcpResidueId).map(id => id.slice(MCP_GROUP_PREFIX.length)).filter(id => !servers[id])
548
+ let candidates = [...new Set([...Object.keys(servers).filter(id => !healthy(id)), ...orphans])]
549
+ if (identity !== null) candidates = candidates.filter(id => id === identity)
550
+ const results = []
551
+ for (const id of candidates) {
552
+ try { results.push(await recoverOne(id, assertOwner)) } catch (cause) {
553
+ if (!assertOwner || cause?.code !== 'owner-changed') throw cause
554
+ const recovered = results.filter(result => result.status === 'mounted' || result.status === 'removed').length
555
+ return {
556
+ status: 'owner-changed', recovered, results,
557
+ error: mcpDiagnostic(cause, { code: 'owner-changed', action: 'inspect-actual-state' }),
558
+ next: { action: 'inspect-actual-state' },
559
+ }
197
560
  }
198
561
  }
199
- try {
200
- await mountOne(entry.identity, (await registry.read()).servers[entry.identity])
201
- return { status: 'mounted' }
202
- } catch (cause) {
203
- return { status: 'mount-failed', reason: message(cause) }
204
- }
562
+ const recovered = results.filter(result => result.status === 'mounted' || result.status === 'removed').length
563
+ return { status: recovered === results.length ? 'recovered' : 'recovery-incomplete', recovered, results }
564
+ }
565
+
566
+ async function adopt(entry) {
567
+ const holder = hostRowHoldingReservation(listRows(), entry.client?.serverName ?? entry.transport?.serverName ?? entry.identity)
568
+ if (holder) return { status: 'conflict', reason: `host row "${holder.id ?? entry.identity}" still holds the serverName reservation — disable it in the host configuration; the managed client mounts afterwards. Until then the server stays unmanaged.` }
569
+ return serialize(entry.identity, async () => {
570
+ const current = await registry.read()
571
+ const revision = current.kind === 'ok' ? current.revision : 0
572
+ const token = beginOperation(entry.identity)
573
+ const registered = await registry.register(entry, revision)
574
+ if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
575
+ const latest = await registry.read()
576
+ return reconcileOne(entry.identity, latest.servers[entry.identity], token)
577
+ })
205
578
  }
206
579
 
207
- /** Creation-time schema hiding (8.4③): per-agent deny of public names whose server is not accepted. */
208
580
  function agentCreated(payload) {
581
+ const agent = rememberAgent(payload?.agent)
582
+ if (!agent) return
583
+ // Creation remains non-throwing for visibility failures; admission is an
584
+ // independent hard gate. Registration itself uses the throwing path so
585
+ // its facade transaction can roll back.
586
+ try { reconcileAgent(agent) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
587
+ }
588
+ function agentDisposed(payload) {
209
589
  const agent = payload?.agent
210
- if (publicNames.size === 0) return
211
- const deny = [...publicNames.entries()].filter(([, identity]) => !gate.enabledFor(agent, identity)).map(([name]) => name)
212
- if (deny.length > 0) {
213
- try { agent?.ctx?.tools?.restrict?.({ deny }) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
214
- }
590
+ if (!isAgent(agent)) return
591
+ // Object identity is load-bearing: a delayed disposal from an old
592
+ // incarnation must never clean the replacement agent of the same session.
593
+ seenAgents.delete(agent)
594
+ disposeAgentRestrictions(agent)
215
595
  }
216
596
  ctx.on?.('agent/created', payload => { agentCreated(payload) })
597
+ ctx.on?.('agent/disposed', payload => { agentDisposed(payload) })
217
598
 
218
- /** Visible startup/mount errors (manager UI + probes). */
219
- const startErrors = []
220
- const face = {
221
- start,
222
- startErrors,
223
- adopt,
224
- gate: gateFace,
599
+ async function list() {
600
+ const current = await registry.read()
601
+ const rows = listRows()
602
+ const partitioned = partitionMcpRows(rows, mount.mounted, current.kind === 'ok' ? current.servers : {})
603
+ if (current.kind === 'absent') return { managed: [], unmanaged: partitioned.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
604
+ if (current.kind !== 'ok') return { managed: [], unmanaged: partitioned.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
605
+ const defaults = new Set(current.enabled)
606
+ const managed = Object.values(current.servers).map(entry => {
607
+ const phase = runtime.snapshot(entry.identity)
608
+ const mountedRecord = mount.mounted.get(entry.identity)
609
+ const mounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
610
+ const holder = hostRowHoldingReservation(rows, entry.client?.serverName ?? entry.identity)
611
+ const state = holder ? 'conflict' : mounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
612
+ const discovery = discoveryFor(entry.identity, entry.generation)
613
+ return {
614
+ identity: entry.identity,
615
+ label: entry.label,
616
+ serverName: entry.client?.serverName ?? entry.identity,
617
+ transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
618
+ incarnation: entry.incarnation ?? null,
619
+ generation: entry.generation,
620
+ defaultEnabled: defaults.has(entry.identity),
621
+ state,
622
+ statusSource: mounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
623
+ runtimePhase: phase.phase,
624
+ discovery: mounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
625
+ lastMount: receipts.mount.get(entry.identity) ?? null,
626
+ lastTest: receipts.test.get(entry.identity) ?? null,
627
+ startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
628
+ liveReconnect: 'unknown',
629
+ processExit: 'unknown',
630
+ stderr: 'unknown',
631
+ readOnly: false,
632
+ config: redactMcpEntry(entry),
633
+ fingerprint: fingerprintMcpEntry(entry),
634
+ }
635
+ })
636
+ return { managed, unmanaged: partitioned.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
637
+ }
638
+
639
+ const gateFace = {
640
+ admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
641
+ permit: (agent, identity, generation) => gate.permit(agent, identity, generation),
642
+ assertPermit: gate.assertPermit,
643
+ enabledFor: (agent, identity) => gate.enabledFor(agent, identity),
225
644
  drain,
226
- registry,
227
- list: () => partitionMcpRows(listRows(), mount.mounted, {}),
645
+ }
646
+ try { ctx.reflect?.provide?.('weirMcpGate', gateFace) } catch { /* older row */ }
647
+ const face = {
648
+ start, startErrors, adopt, gate: gateFace, drain, registry, runtime,
649
+ recoverResidue,
650
+ list, syncSession,
228
651
  publicNames,
229
- remove: identity => mount.unmount(identity, dependencies.unmountGuards),
652
+ mounted: mount.mounted,
653
+ beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
654
+ remove: identity => serialize(identity, () => removeRuntime(identity)),
655
+ /** Run one same-identity operation after every earlier one settled. */
656
+ serialize,
657
+ support,
658
+ serverNameConflict: (serverName, identity) => {
659
+ const holder = hostRowHoldingReservation(listRows(), serverName)
660
+ return holder ? `host row "${holder.id ?? identity}" already reserves serverName "${serverName}"` : null
661
+ },
662
+ prepare, describeExecution,
663
+ mcpGroupOptions,
230
664
  }
231
665
  try { ctx.reflect?.provide?.('weirMcpManager', face) } catch { /* older row */ }
232
-
233
- // Startup mounting is fire-and-forget (like the 6.1 preload): a slow or
234
- // failed start only costs one visible error per server, never a broken
235
- // preset row.
236
- void Promise.resolve()
237
- .then(() => face.start())
238
- .catch(cause => {
239
- startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
240
- onError('mcp-manager-start-failed', cause)
241
- })
242
-
243
- // Fail closed on the dispose/reload window (8.5): managed calls are
244
- // refused because the gate has no initialized authority anymore.
666
+ void Promise.resolve().then(() => face.start()).catch(cause => {
667
+ startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
668
+ onError('mcp-manager-start-failed', cause)
669
+ })
245
670
  return () => {
246
- try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
671
+ try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
672
+ // Child-group disposal normally unregisters tools first. If any public
673
+ // fact remains, unregistration was not confirmed: KEEP its deny instead
674
+ // of exposing a still-registered tool during manager teardown.
675
+ for (const agent of [...restrictions.keys()]) disposeAgentRestrictions(agent, { onlyAbsent: true })
247
676
  }
248
677
  }
249
678
  }
250
679
 
251
680
  export const name = 'weir-mcp-manager'
252
- export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources']
681
+ export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources', 'agents']
253
682
  export const apply = createMcpManager()
254
- export const __message = message // test seam
683
+ export const __message = message