weir-harness 2.1.0 → 2.1.2

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.
@@ -5,10 +5,13 @@ import { openCapabilityStore } from './store/store.js'
5
5
  import { createMcpRegistry } from './mcp-registry.js'
6
6
  import { createMcpGate } from './mcp-gate.js'
7
7
  import { createMcpDrain } from './mcp-drain.js'
8
- import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX, resolveEphemeralMcpTree } from './mcp-mount.js'
8
+ import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX, resolveEphemeralMcpTree, resolveScopedMcpTree } from './mcp-mount.js'
9
9
  import { registerMcpFacadeBridge } from './mcp-facade-plugin.js'
10
- import { skillSelectionFor } from './skill-selection-plugin.js'
11
- import { createMcpRuntime, createMcpIdentityLock } from './mcp-runtime.js'
10
+ import { mcpMountOwnership } from './mcp-mount-ownership.js'
11
+ import { lifecycleFeedRegistry } from './lifecycle-feed.js'
12
+ import { createMcpRuntime } from './mcp-runtime.js'
13
+ import { createMcpOperationCoordinator } from './mcp-operation-coordinator.js'
14
+ import { createMcpInventoryProjection, partitionMcpInventory } from './mcp-inventory.js'
12
15
  import { materializeMcpClientConfig } from './mcp-credentials.js'
13
16
  import { hasMcpSecrets, redactMcpEntry, fingerprintMcpEntry } from './mcp-config.js'
14
17
  import { createMcpProcessAdapter } from './mcp-process.js'
@@ -17,19 +20,8 @@ import { mcpDiagnostic, sanitizeMcpText } from './mcp-diagnostics.js'
17
20
  const message = error => (error instanceof Error ? error.message : String(error))
18
21
  const now = () => Date.now()
19
22
 
20
- export function partitionMcpRows(rows, mounted, registryServers) {
21
- const managed = []
22
- const unmanaged = []
23
- for (const row of rows) {
24
- if (typeof row?.id === 'string' && isMcpResidueId(row.id)) {
25
- const identity = row.id.slice(MCP_GROUP_PREFIX.length)
26
- const registry = registryServers[identity]
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' })
28
- } else if (row?.name === '@deepseek-ai/dsh-mcp-client' && !(typeof row.id === 'string' && row.id.startsWith(MCP_GROUP_PREFIX))) {
29
- unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged', readOnly: true })
30
- }
31
- }
32
- return { managed, unmanaged }
23
+ export function partitionMcpRows(rows, mounted, registryServers, ownership = mcpMountOwnership) {
24
+ return partitionMcpInventory(rows, mounted, registryServers, { isResidue: isMcpResidueId, groupPrefix: MCP_GROUP_PREFIX, ownership })
33
25
  }
34
26
 
35
27
  export function hostRowHoldingReservation(rows, serverName) {
@@ -42,28 +34,36 @@ export function createMcpManager(dependencies = {}) {
42
34
  const profileContext = ctx.get?.('profileContext')
43
35
  const store = dependencies.store ?? openCapabilityStore({ profileContext })
44
36
  const registry = dependencies.registry ?? createMcpRegistry({ store })
37
+ let defaultLifecycleActive = true
45
38
  const lifecycle = dependencies.lifecycle ?? {
46
- snapshotFor: sessionId => skillSelectionFor(ctx)?.lifecycle?.snapshotFor(sessionId) ?? null,
39
+ snapshotFor(sessionId) {
40
+ if (!defaultLifecycleActive) return null
41
+ const owningLifecycle = lifecycleFeedRegistry.lifecycleFor(face)
42
+ return owningLifecycle?.snapshotFor?.(sessionId) ?? null
43
+ },
47
44
  }
48
45
  const runtime = dependencies.runtime ?? createMcpRuntime()
49
- // Every runtime.begin path runs inside this per-identity queue (design D6).
50
- const serialize = createMcpIdentityLock()
51
46
  const drain = dependencies.drain ?? createMcpDrain({ timeoutMs: config.drainTimeoutMs ?? 30_000 })
47
+ const coordinator = createMcpOperationCoordinator({ runtime, drain, transition: transitionRuntime })
48
+ const { serialize, beginOperation, finishOperation, abortOperation } = coordinator
52
49
  const gate = dependencies.gate ?? createMcpGate({ registry, lifecycle, runtime, registrationFor: runtime.registrationFor })
53
50
  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 ?? {} }))
55
- const listEntryIds = dependencies.listEntryIds ?? (() => defaultListRows().map(row => row.id).filter(Boolean))
56
- const listRows = dependencies.listRows ?? defaultListRows
51
+ const ownership = dependencies.ownership ?? mcpMountOwnership
52
+ const defaultHostRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({ id: entry?.options?.id ?? entry?.id ?? null, name: entry?.options?.name ?? null, config: entry?.options?.config ?? {} }))
57
53
  const startErrors = []
58
54
  /** A strict mount of this identity supersedes its earlier start/mount failures. */
59
55
  const clearStartErrors = identity => {
60
56
  for (let index = startErrors.length - 1; index >= 0; index -= 1) if (startErrors[index].identity === identity) startErrors.splice(index, 1)
61
57
  }
62
58
  const onError = (reason, cause) => ctx.logger?.warn?.(sanitizeMcpText(`${reason}${cause ? `: ${message(cause)}` : ''}`))
63
- const defaultFiberOf = async groupId => {
59
+ const owningTree = ctx.fiber?.entry?.parent?.tree ?? null
60
+ const scopedTree = owningTree ? resolveScopedMcpTree(owningTree) : undefined
61
+ const defaultFiberOf = async (groupId, context = {}) => {
62
+ const tree = context.tree ?? mount.treeForGroup?.(groupId)
63
+ const resolve = tree?.resolve ?? (id => loader?.resolve?.(id))
64
64
  for (const candidate of [`${groupId}:${groupId}-client`, `${groupId}-client`]) {
65
65
  let entry = null
66
- try { entry = loader?.resolve?.(candidate) } catch { /* next shape */ }
66
+ try { entry = resolve?.(candidate) } catch { /* next shape */ }
67
67
  if (entry?.fiber) {
68
68
  await entry.fiber.await()
69
69
  return { state: 'running' }
@@ -71,15 +71,23 @@ export function createMcpManager(dependencies = {}) {
71
71
  }
72
72
  return null
73
73
  }
74
- const mount = dependencies.mount ?? createMcpMount({ loader, onError, startupTimeoutMs: config.startupTimeoutMs ?? 15_000, cleanupTimeoutMs: config.cleanupTimeoutMs ?? 15_000 })
74
+ const mount = dependencies.mount ?? createMcpMount({
75
+ loader, onError, ownership,
76
+ startupTimeoutMs: config.startupTimeoutMs ?? 15_000,
77
+ cleanupTimeoutMs: config.cleanupTimeoutMs ?? 15_000,
78
+ ...(scopedTree !== undefined ? { ephemeralTree: scopedTree } : {}),
79
+ })
75
80
  const fiberOf = dependencies.fiberOf ?? defaultFiberOf
81
+ const listRows = dependencies.listRows ?? (() => [...defaultHostRows(), ...(mount.listRows?.() ?? [])])
82
+ const listEntryIds = dependencies.listEntryIds ?? (() => listRows().map(row => row.id).filter(Boolean))
76
83
  const processAdapter = dependencies.processAdapter ?? createMcpProcessAdapter({ subprocess: dependencies.subprocess ?? ctx.get?.('subprocess') })
77
84
  const credentialsFor = () => dependencies.credentials ?? ctx.get?.('credentials')
78
85
  const support = () => {
79
86
  const credentials = credentialsFor()
80
- return { credentials: typeof credentials?.set === 'function' && typeof credentials?.resolve === 'function', ephemeral: resolveEphemeralMcpTree(loader).kind === 'ok' }
87
+ return { credentials: typeof credentials?.set === 'function' && typeof credentials?.resolve === 'function', ephemeral: scopedTree?.kind === 'ok' || resolveEphemeralMcpTree(loader).kind === 'ok' }
81
88
  }
82
89
  const publicNames = new Map() // publicName → { identity, generation }
90
+ const publicOwnerAttempts = new WeakMap() // plain public owner object → internal bridge attempt
83
91
  const bridges = new Map() // groupId → { identity, generation, release }
84
92
  const receipts = { mount: new Map(), test: new Map() }
85
93
  const host = {
@@ -87,43 +95,232 @@ export function createMcpManager(dependencies = {}) {
87
95
  systemPrompt: dependencies.hostSystemPrompt ?? ctx.get?.('systemPrompt'),
88
96
  mcpResources: dependencies.hostMcpResources ?? ctx.get?.('mcpResources'),
89
97
  }
98
+ const agentsService = dependencies.agents ?? ctx.get?.('agents')
99
+ const seenAgents = new Set() // compatibility fallback; actual agent objects, never session ids
100
+ const restrictions = new Map() // agent → Map(publicName → { owner, dispose })
101
+
102
+ const identityOf = owner => typeof owner === 'string' ? owner : owner?.identity
103
+ const attemptOf = owner => owner && (typeof owner === 'object' || typeof owner === 'function') ? publicOwnerAttempts.get(owner) : null
104
+ const sameOwner = (owner, identity, generation, attempt = null) => {
105
+ const same = typeof owner === 'string'
106
+ ? owner === identity // legacy test facts carried no generation
107
+ : owner?.identity === identity && owner?.generation === generation
108
+ return same && (!attempt || attemptOf(owner) === attempt)
109
+ }
110
+ const isAgent = agent => (typeof agent === 'object' && agent !== null) || typeof agent === 'function'
111
+ function rememberAgent(agent) {
112
+ if (!isAgent(agent)) return null
113
+ seenAgents.add(agent)
114
+ return agent
115
+ }
116
+ function liveAgents() {
117
+ if (typeof agentsService?.list !== 'function') return [...seenAgents]
118
+ const listed = agentsService.list()
119
+ if (!Array.isArray(listed)) throw new TypeError('agents.list() must return an array')
120
+ for (const agent of listed) rememberAgent(agent)
121
+ return listed
122
+ }
123
+
124
+ /**
125
+ * Reconcile only actual differences. Every held entry owns exactly one
126
+ * independent deny disposer; absent public names never reach restrict().
127
+ */
128
+ function reconcileAgent(agent, matches = () => true, added = null) {
129
+ if (!isAgent(agent)) return
130
+ let held = restrictions.get(agent)
131
+ const names = new Set()
132
+ for (const [name, owner] of publicNames) if (matches(name, owner)) names.add(name)
133
+ for (const [name, record] of held ?? []) if (matches(name, record.owner)) names.add(name)
134
+ let failure = null
135
+ for (const name of names) {
136
+ try {
137
+ const present = publicNames.has(name)
138
+ const owner = publicNames.get(name)
139
+ const existing = held?.get(name)
140
+ const definition = attemptOf(owner)?.definitions?.get(name)
141
+ let foreignDefinition = false
142
+ if (owningTree && definition && (!existing || existing.owner !== owner) && typeof host.tools?.get === 'function') {
143
+ try { foreignDefinition = host.tools.get(name, agent) !== definition } catch { /* fail closed through the normal gate deny */ }
144
+ }
145
+ const shouldDeny = present && !foreignDefinition && !gate.enabledFor(agent, identityOf(owner))
146
+ if (shouldDeny && !existing) {
147
+ const restrict = agent?.ctx?.tools?.restrict
148
+ if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
149
+ const disposer = restrict.call(agent.ctx.tools, { deny: [name] })
150
+ if (!held) {
151
+ held = new Map()
152
+ restrictions.set(agent, held)
153
+ }
154
+ const record = { owner, dispose: typeof disposer === 'function' ? disposer : null }
155
+ held.set(name, record)
156
+ added?.push({ agent, name, record })
157
+ } else if (!shouldDeny && existing) {
158
+ existing.dispose?.()
159
+ held.delete(name)
160
+ } else if (shouldDeny && existing) {
161
+ // A replacement may reuse the public name. Keep the exact deny and
162
+ // update only its ownership metadata; never dispose/recreate it.
163
+ existing.owner = owner
164
+ }
165
+ } catch (cause) {
166
+ failure ??= cause
167
+ }
168
+ }
169
+ if (held?.size === 0) restrictions.delete(agent)
170
+ if (failure) throw failure
171
+ }
172
+
173
+ function reconcileAgents(matches, added = null) {
174
+ let failure = null
175
+ for (const agent of liveAgents()) {
176
+ try { reconcileAgent(agent, matches, added) } catch (cause) { failure ??= cause }
177
+ }
178
+ if (failure) throw failure
179
+ }
180
+ const syncName = (name, added = null) => reconcileAgents(candidate => candidate === name, added)
181
+ const syncIdentity = identity => reconcileAgents((_name, owner) => identityOf(owner) === identity)
182
+
183
+ function syncSession(sessionId) {
184
+ let agent
185
+ if (typeof agentsService?.get === 'function') agent = agentsService.get(sessionId)
186
+ else agent = [...seenAgents].find(candidate => candidate?.session?.id === sessionId)
187
+ if (rememberAgent(agent)) reconcileAgent(agent)
188
+ }
189
+
190
+ function registerPublicName(name, identity, generation, captureRollback, attempt = null, definition = null) {
191
+ const hadPrevious = publicNames.has(name)
192
+ const previous = publicNames.get(name)
193
+ const attempted = { identity, generation }
194
+ // Preserve the public {identity,generation} shape while binding exact
195
+ // attempt identity for same-generation concurrent/stale cleanup.
196
+ if (attempt) publicOwnerAttempts.set(attempted, attempt)
197
+ if (attempt && definition) attempt.definitions.set(name, definition)
198
+ const previousDenies = []
199
+ for (const [agent, held] of restrictions) {
200
+ const record = held.get(name)
201
+ if (record) previousDenies.push({ agent, record, owner: record.owner })
202
+ }
203
+ const added = []
204
+ publicNames.set(name, attempted)
205
+ captureRollback?.(() => {
206
+ // Object identity distinguishes attempts even within one generation.
207
+ // A newer owner must never be overwritten by a stale Host rollback.
208
+ if (publicNames.get(name) !== attempted) return
209
+ let failure = null
210
+ const fail = cause => {
211
+ failure ??= cause
212
+ onError(`mcp-schema-rollback-failed:${name}`, cause)
213
+ }
214
+ if (hadPrevious) publicNames.set(name, previous)
215
+ else publicNames.delete(name)
216
+
217
+ // Recreate denies removed by the failed sync before reusing surviving
218
+ // records, then dispose only denies introduced by this attempt.
219
+ for (const { agent, owner } of previousDenies) {
220
+ if (restrictions.get(agent)?.has(name)) continue
221
+ try {
222
+ const restrict = agent?.ctx?.tools?.restrict
223
+ if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
224
+ const disposer = restrict.call(agent.ctx.tools, { deny: [name] })
225
+ let held = restrictions.get(agent)
226
+ if (!held) {
227
+ held = new Map()
228
+ restrictions.set(agent, held)
229
+ }
230
+ held.set(name, { owner, dispose: typeof disposer === 'function' ? disposer : null })
231
+ } catch (cause) { fail(cause) }
232
+ }
233
+ for (const { agent, record, owner } of previousDenies) {
234
+ if (restrictions.get(agent)?.get(name) === record) record.owner = owner
235
+ }
236
+ for (const { agent, record } of added.reverse()) {
237
+ const held = restrictions.get(agent)
238
+ if (held?.get(name) !== record) continue
239
+ try {
240
+ record.dispose?.()
241
+ if (held.get(name) === record) held.delete(name)
242
+ if (held.size === 0) restrictions.delete(agent)
243
+ } catch (cause) { fail(cause) }
244
+ }
245
+ if (failure) throw failure
246
+ })
247
+ // Until confirmed Host disposal, retain the attempted owner and every
248
+ // partial deny. The facade invokes only this transaction's rollback.
249
+ syncName(name, added)
250
+ }
251
+
252
+ function unregisterPublicName(name, identity, generation, attempt = null) {
253
+ if (!sameOwner(publicNames.get(name), identity, generation, attempt)) return false
254
+ publicNames.delete(name)
255
+ try { syncName(name) } catch (cause) { onError(`mcp-schema-cleanup-failed:${name}`, cause) }
256
+ return true
257
+ }
258
+
259
+ function disposeAgentRestrictions(agent, { onlyAbsent = false } = {}) {
260
+ const held = restrictions.get(agent)
261
+ if (!held) return
262
+ for (const [name, record] of held) {
263
+ if (onlyAbsent && publicNames.has(name)) continue
264
+ try {
265
+ record.dispose?.()
266
+ held.delete(name)
267
+ } catch (cause) {
268
+ // Cleanup is fail-closed: an unconfirmed disposer stays owned here.
269
+ onError(`mcp-schema-dispose-failed:${name}`, cause)
270
+ }
271
+ }
272
+ if (held.size === 0) restrictions.delete(agent)
273
+ }
90
274
 
91
- function releaseBridge(groupId) {
275
+ function releaseBridge(groupId, expected) {
92
276
  const bridge = bridges.get(groupId)
93
- if (!bridge) return
277
+ if (!bridge || !expected || bridge !== expected) return false
94
278
  bridges.delete(groupId)
95
- for (const [name, owner] of publicNames) {
96
- if (owner.identity === bridge.identity && owner.generation === bridge.generation) publicNames.delete(name)
279
+ for (const [name, owner] of [...publicNames]) {
280
+ if (sameOwner(owner, bridge.identity, bridge.generation, bridge)) unregisterPublicName(name, bridge.identity, bridge.generation, bridge)
97
281
  }
98
282
  bridge.release()
283
+ bridge.definitions.clear()
284
+ return true
99
285
  }
100
- function registerBridge(groupId, identity, generation) {
286
+ function registerBridge(groupId, identity, generation, owner = null) {
287
+ if (bridges.has(groupId)) throw Object.assign(new Error(`MCP manager group ${groupId} already has a bridge`), { code: 'duplicate-mcp-facade-owner' })
288
+ const record = { groupId, identity, generation, owner, release: null, definitions: new Map() }
101
289
  const release = registerMcpFacadeBridge(groupId, {
102
290
  host, identity, generation,
103
291
  admit: agent => gate.permit(agent, identity, generation),
104
292
  assertPermit: gate.assertPermit,
105
293
  isEnabledFor: agent => gate.enabledFor(agent, identity),
106
294
  drain,
107
- onRegister: name => publicNames.set(name, { identity, generation }),
295
+ onRegister: (name, captureRollback, definition) => registerPublicName(name, identity, generation, captureRollback, record, definition),
296
+ onUnregister: name => unregisterPublicName(name, identity, generation, record),
108
297
  onError,
109
- })
110
- bridges.set(groupId, { identity, generation, release })
298
+ }, owner ?? record)
299
+ record.release = release
300
+ bridges.set(groupId, record)
301
+ return record
111
302
  }
112
- function discoveryFor(identity, generation) {
113
- const names = [...publicNames.entries()].filter(([, owner]) => owner.identity === identity && owner.generation === generation).map(([name]) => name).sort()
303
+ function discoveryFor(identity, generation, attempt = null) {
304
+ const currentGroup = mount.mounted.get(identity)?.groupId ?? null
305
+ const names = [...publicNames.entries()].filter(([, owner]) => {
306
+ if (owner.identity !== identity || owner.generation !== generation) return false
307
+ if (attempt) return attemptOf(owner) === attempt
308
+ return !attemptOf(owner) || attemptOf(owner).groupId === currentGroup
309
+ }).map(([name]) => name).sort()
114
310
  return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
115
311
  }
116
312
 
117
- function beginOperation(identity, phase = 'replacing', details = {}) {
118
- const token = runtime.begin(identity, { phase, ...details })
119
- drain.closeAll(identity)
120
- return token
121
- }
122
- function abortOperation(token) {
123
- const previousPhase = token.previous?.phase
124
- const restored = runtime.restore(token)
125
- if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
126
- return restored
313
+ /** Every manager-owned runtime mutation converges visibility after the actual state change. */
314
+ function transitionRuntime(identity, change) {
315
+ const before = runtime.snapshot(identity)
316
+ const result = change()
317
+ const after = runtime.snapshot(identity)
318
+ const changed = before.phase !== after.phase || before.generation !== after.generation
319
+ || (before.incarnation ?? null) !== (after.incarnation ?? null) || before.operationId !== after.operationId
320
+ if (changed) {
321
+ try { syncIdentity(identity) } catch (cause) { onError(`mcp-visibility-sync-failed:${identity}`, cause) }
322
+ }
323
+ return result
127
324
  }
128
325
  async function waitForDrain(identity) {
129
326
  const result = await drain.drainAll(identity)
@@ -159,8 +356,11 @@ export function createMcpManager(dependencies = {}) {
159
356
  return (await processAdapter.prepare(client)).execution
160
357
  }
161
358
 
162
- async function reconcileOne(identity, entry, suppliedToken = null) {
163
- const token = suppliedToken ?? beginOperation(identity)
359
+ function reconcileOne(identity, entry, suppliedToken = null) {
360
+ if (suppliedToken !== null) return reconcileFenced(identity, entry, suppliedToken)
361
+ return serialize(identity, () => reconcileFenced(identity, entry, beginOperation(identity)))
362
+ }
363
+ async function reconcileFenced(identity, entry, token) {
164
364
  try {
165
365
  const current = await registry.read()
166
366
  const registered = current.kind === 'ok' ? current.servers[identity] : null
@@ -171,15 +371,20 @@ export function createMcpManager(dependencies = {}) {
171
371
  }
172
372
  await waitForDrain(identity)
173
373
  if (mount.mounted.has(identity)) {
174
- const removed = await mount.unmount(identity)
374
+ const previousMount = mount.mounted.get(identity)
375
+ const previousBridge = bridges.get(previousMount.groupId) ?? null
376
+ const afterCleanup = () => releaseBridge(previousMount.groupId, previousBridge)
377
+ const removed = await mount.unmount(identity, {}, { afterCleanup })
175
378
  if (removed.status !== 'cleaned') {
176
- runtime.finish(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
379
+ finishOperation(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
177
380
  return { status: removed.status, identity, cleanup: removed, saved: true }
178
381
  }
179
- releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
382
+ // Legacy mount doubles do not consume context; exact compare-delete
383
+ // makes this compatibility fallback harmless for the real mount.
384
+ afterCleanup()
180
385
  }
181
386
  const prepared = await prepare(entry)
182
- const groupId = `${MCP_GROUP_PREFIX}${identity}`
387
+ let attempt = null
183
388
  const result = await mount.mount({
184
389
  identity,
185
390
  generation: entry.generation,
@@ -188,9 +393,13 @@ export function createMcpManager(dependencies = {}) {
188
393
  secretBearing: prepared.secretBearing,
189
394
  execution: prepared.execution,
190
395
  fiberOf,
191
- beforeCreate: () => registerBridge(groupId, identity, entry.generation),
192
- afterCleanup: () => releaseBridge(groupId),
193
- discovery: () => discoveryFor(identity, entry.generation),
396
+ beforeCreate: (context = {}) => {
397
+ attempt = registerBridge(context.groupId ?? `${MCP_GROUP_PREFIX}${identity}`, identity, entry.generation, context.owner ?? null)
398
+ },
399
+ afterCleanup: (context = {}) => {
400
+ if (attempt) releaseBridge(context.groupId ?? attempt.groupId, attempt)
401
+ },
402
+ discovery: () => discoveryFor(identity, entry.generation, attempt),
194
403
  readback: async () => {
195
404
  const readback = await registry.read()
196
405
  const actual = readback.kind === 'ok' ? readback.servers[identity] : null
@@ -201,12 +410,11 @@ export function createMcpManager(dependencies = {}) {
201
410
  if (!observed || observed.generation !== entry.generation) throw new Error('mounted generation readback failed')
202
411
  // Only the fence owner may report mounted and reopen admission: a
203
412
  // superseded token means another operation now owns this identity.
204
- if (!runtime.finish(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
413
+ if (!finishOperation(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
205
414
  const diagnostic = mcpDiagnostic(new Error(`MCP operation on ${identity} was superseded before it could report mounted`), { code: 'operation-superseded', action: 'reread-registry' })
206
415
  receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
207
416
  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' } }
208
417
  }
209
- drain.reopenAll(identity)
210
418
  clearStartErrors(identity)
211
419
  const receipt = { time: now(), status: 'mounted', generation: entry.generation, discovery: result.discovery }
212
420
  receipts.mount.set(identity, receipt)
@@ -214,7 +422,7 @@ export function createMcpManager(dependencies = {}) {
214
422
  } catch (cause) {
215
423
  const cleanupStatus = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : null
216
424
  const status = cleanupStatus ?? (cause?.code === 'unsupported' ? 'unsupported' : 'saved-degraded')
217
- runtime.finish(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
425
+ finishOperation(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
218
426
  const diagnostic = mcpDiagnostic(cause, { code: cause?.code, action: cleanupStatus ? 'recover-residue' : 'retry' })
219
427
  receipts.mount.set(identity, { time: now(), status, diagnostic })
220
428
  startErrors.push({ identity, reason: diagnostic.message })
@@ -223,26 +431,34 @@ export function createMcpManager(dependencies = {}) {
223
431
  }
224
432
  }
225
433
 
226
- async function removeRuntime(identity, suppliedToken = null) {
227
- const token = suppliedToken ?? beginOperation(identity, 'removing')
434
+ function removeRuntime(identity, suppliedToken = null) {
435
+ if (suppliedToken !== null) return removeRuntimeFenced(identity, suppliedToken)
436
+ return serialize(identity, () => removeRuntimeFenced(identity, beginOperation(identity, 'removing')))
437
+ }
438
+ async function removeRuntimeFenced(identity, token) {
228
439
  try {
229
440
  await waitForDrain(identity)
230
- const cleanup = await mount.unmount(identity)
441
+ const mountedRecord = mount.mounted.get(identity) ?? null
442
+ const groupId = mountedRecord?.groupId ?? null
443
+ const bridge = groupId ? bridges.get(groupId) ?? null : null
444
+ const afterCleanup = () => { if (groupId) releaseBridge(groupId, bridge) }
445
+ const cleanup = await mount.unmount(identity, {}, { afterCleanup })
231
446
  if (cleanup.status !== 'cleaned') {
232
- runtime.finish(token, cleanup.status)
447
+ finishOperation(token, cleanup.status)
233
448
  return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
234
449
  }
235
- releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
236
- runtime.finish(token, 'unknown', { generation: null, incarnation: null })
450
+ afterCleanup()
451
+ finishOperation(token, 'unknown', { generation: null, incarnation: null })
237
452
  return { status: 'removed', identity, removed: true }
238
453
  } catch (cause) {
239
454
  const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
240
- runtime.finish(token, status)
455
+ finishOperation(token, status)
241
456
  return { status, identity, error: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }), next: { action: 'recover-residue' } }
242
457
  }
243
458
  }
244
459
 
245
460
  async function probe(entry, { targetIdentity = entry.identity } = {}) {
461
+ let attempt = null
246
462
  try {
247
463
  const prepared = await prepare(entry)
248
464
  const result = await mount.probe({
@@ -252,9 +468,13 @@ export function createMcpManager(dependencies = {}) {
252
468
  secretBearing: prepared.secretBearing,
253
469
  secrets: prepared.secrets,
254
470
  fiberOf,
255
- beforeCreate: ({ groupId, identity, generation }) => registerBridge(groupId, identity, generation),
256
- afterCleanup: ({ groupId }) => releaseBridge(groupId),
257
- discovery: ({ identity, generation }) => discoveryFor(identity, generation),
471
+ beforeCreate: (context = {}) => {
472
+ attempt = registerBridge(context.groupId ?? `${MCP_GROUP_PREFIX}probe`, context.identity ?? 'probe', context.generation ?? 0, context.owner ?? null)
473
+ },
474
+ afterCleanup: (context = {}) => {
475
+ if (attempt) releaseBridge(context.groupId ?? attempt.groupId, attempt)
476
+ },
477
+ discovery: context => discoveryFor(context?.identity ?? attempt?.identity ?? 'probe', context?.generation ?? 0, attempt),
258
478
  })
259
479
  const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
260
480
  // Receipts share one shape with lastMount: the row reads `diagnostic`.
@@ -285,60 +505,100 @@ export function createMcpManager(dependencies = {}) {
285
505
  }
286
506
  let mountedCount = 0
287
507
  for (const entry of Object.values(current.servers)) {
288
- const result = await serialize(entry.identity, () => reconcileOne(entry.identity, entry))
508
+ const result = await reconcileOne(entry.identity, entry)
289
509
  if (result.status === 'mounted') mountedCount += 1
290
510
  else if (result.status !== 'saved-degraded') startErrors.push({ identity: entry.identity, reason: result.status })
291
511
  }
292
512
  return { mounted: mountedCount }
293
513
 
294
514
  }
295
- const healthy = identity => runtime.snapshot(identity).phase === 'mounted' && mount.mounted.has(identity)
515
+ const healthy = (identity, state = runtime.snapshot(identity)) => state?.phase === 'mounted' && mount.mounted.has(identity)
296
516
  && (mount.mounted.get(identity)?.state ?? 'mounted') === 'mounted'
297
517
 
298
- /** Recover one identity inside its queue slot: sweep untracked residue, then strictly remount. */
518
+ async function sweepRecoveryGroups(groupIds) {
519
+ const expected = new Map(groupIds.map(groupId => [groupId, bridges.get(groupId) ?? null]))
520
+ const afterCleanup = ({ groupId }) => {
521
+ const bridge = expected.get(groupId)
522
+ if (bridge) releaseBridge(groupId, bridge)
523
+ }
524
+ if (typeof mount.sweepResidueDetailed === 'function') {
525
+ return mount.sweepResidueDetailed(groupIds, { afterCleanup })
526
+ }
527
+ const swept = await mount.sweepResidue(groupIds)
528
+ for (const groupId of swept) afterCleanup({ groupId })
529
+ return { swept, blocked: groupIds.filter(id => !swept.includes(id)), foreign: [], unresolved: [] }
530
+ }
531
+
532
+ function residueIdentity(groupId) {
533
+ if (typeof mount.identityForGroup === 'function') return mount.identityForGroup(groupId)?.identity ?? null
534
+ return isMcpResidueId(groupId) ? groupId.slice(MCP_GROUP_PREFIX.length) : null
535
+ }
536
+
537
+ function residueGroups(identity) {
538
+ const listed = listEntryIds().filter(isMcpResidueId).filter(groupId => residueIdentity(groupId) === identity)
539
+ for (const [groupId, bridge] of bridges) if (bridge.identity === identity) listed.push(groupId)
540
+ return [...new Set(listed)]
541
+ }
542
+
543
+ /** Recover one identity inside its queue slot using exact actual group IDs. */
299
544
  function recoverOne(identity, assertOwner) {
300
545
  return serialize(identity, async () => {
301
- // Re-read inside the slot: an earlier queued operation may have
302
- // remounted, edited or removed this identity meanwhile.
303
- const current = await registry.read()
546
+ // Ownership can still reject a queued no-op without touching admission.
547
+ // Once recovery is fenced, the fence precedes its identity reread.
304
548
  if (assertOwner) await assertOwner('recover-cleanup')
305
- if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', identity, skipped: current.kind }
306
- const entry = current.kind === 'ok' ? current.servers[identity] ?? null : null
307
- if (entry && healthy(identity)) return { status: 'mounted', identity, generation: entry.generation, alreadyHealthy: true }
308
- const groupId = `${MCP_GROUP_PREFIX}${identity}`
309
- const residuePresent = listEntryIds().includes(groupId)
310
- if (!entry) {
311
- // Orphan residue of an identity that is no longer registered.
312
- const swept = residuePresent ? await mount.sweepResidue([groupId]) : []
313
- const cleaned = !residuePresent || swept.includes(groupId)
314
- if (cleaned) releaseBridge(groupId)
315
- return cleaned
316
- ? { status: 'removed', identity, swept: residuePresent }
317
- : { 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' } }
318
- }
319
549
  const token = beginOperation(identity)
320
- // A tracked failed mount record is retried by reconcileOne's unmount; a
321
- // Loader row the mount map no longer tracks is swept first, or the
322
- // same-name replacement could never be created.
323
- if (!mount.mounted.has(identity) && residuePresent) {
324
- const swept = await mount.sweepResidue([groupId])
325
- if (!swept.includes(groupId)) {
326
- runtime.finish(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
327
- const diagnostic = mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
328
- receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
329
- return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
550
+ let terminal = false
551
+ try {
552
+ const current = await registry.read()
553
+ if (current.kind !== 'ok' && current.kind !== 'absent') {
554
+ terminal = true
555
+ abortOperation(token)
556
+ return { status: 'unavailable', identity, skipped: current.kind }
330
557
  }
331
- releaseBridge(groupId)
332
- }
333
- if (assertOwner) {
334
- try { await assertOwner('mount') } catch (cause) {
335
- // Cleanup may already have removed the old mount. Keep admission
336
- // closed without stranding the queue fence in `replacing`.
337
- runtime.finish(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
338
- throw cause
558
+ const entry = current.kind === 'ok' ? current.servers[identity] ?? null : null
559
+ if (entry && healthy(identity, token.previous)) {
560
+ terminal = true
561
+ abortOperation(token)
562
+ return { status: 'mounted', identity, generation: entry.generation, alreadyHealthy: true }
563
+ }
564
+ const groups = residueGroups(identity)
565
+ if (!entry) {
566
+ // Only own or synchronously claimed inactive residue is swept. A
567
+ // foreign active group is retained and never reported as removed.
568
+ const detail = groups.length > 0 ? await sweepRecoveryGroups(groups) : { swept: [], blocked: [], foreign: [], unresolved: [] }
569
+ const cleaned = detail.blocked.length === 0 && detail.unresolved.length === 0 && detail.foreign.length === 0
570
+ terminal = true
571
+ finishOperation(token, cleaned ? 'unknown' : 'cleanup-failed', { generation: null, incarnation: null })
572
+ return cleaned
573
+ ? { status: 'removed', identity, swept: groups.length > 0 }
574
+ : { status: 'cleanup-failed', identity, error: mcpDiagnostic(new Error(`residue Loader group(s) for ${identity} could not be safely removed`), { code: 'cleanup-failed', action: 'recover-residue' }), next: { action: 'recover-residue' } }
575
+ }
576
+ // A tracked failed mount is unmounted by reconcileOne using its actual
577
+ // ID. Only untracked groups are residue candidates here; foreign active
578
+ // groups are skipped and do not become this manager's mount evidence.
579
+ if (!mount.mounted.has(identity) && groups.length > 0) {
580
+ const detail = await sweepRecoveryGroups(groups)
581
+ if (detail.blocked.length > 0 || detail.unresolved.length > 0) {
582
+ terminal = true
583
+ finishOperation(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
584
+ const diagnostic = mcpDiagnostic(new Error(`owned residue Loader group(s) for ${identity} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
585
+ receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
586
+ return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
587
+ }
339
588
  }
589
+ if (assertOwner) {
590
+ try { await assertOwner('mount') } catch (cause) {
591
+ terminal = true
592
+ finishOperation(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
593
+ throw cause
594
+ }
595
+ }
596
+ terminal = true // reconcileFenced owns every transition from here.
597
+ return reconcileOne(identity, entry, token)
598
+ } catch (cause) {
599
+ if (!terminal) abortOperation(token)
600
+ throw cause
340
601
  }
341
- return reconcileOne(identity, entry, token)
342
602
  })
343
603
  }
344
604
 
@@ -354,7 +614,7 @@ export function createMcpManager(dependencies = {}) {
354
614
  const current = await registry.read()
355
615
  if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', skipped: current.kind, results: [] }
356
616
  const servers = current.kind === 'ok' ? current.servers : {}
357
- const orphans = listEntryIds().filter(isMcpResidueId).map(id => id.slice(MCP_GROUP_PREFIX.length)).filter(id => !servers[id])
617
+ const orphans = listEntryIds().filter(isMcpResidueId).map(residueIdentity).filter(id => id && !servers[id])
358
618
  let candidates = [...new Set([...Object.keys(servers).filter(id => !healthy(id)), ...orphans])]
359
619
  if (identity !== null) candidates = candidates.filter(id => id === identity)
360
620
  const results = []
@@ -377,65 +637,47 @@ export function createMcpManager(dependencies = {}) {
377
637
  const holder = hostRowHoldingReservation(listRows(), entry.client?.serverName ?? entry.transport?.serverName ?? entry.identity)
378
638
  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.` }
379
639
  return serialize(entry.identity, async () => {
380
- const current = await registry.read()
381
- const revision = current.kind === 'ok' ? current.revision : 0
382
640
  const token = beginOperation(entry.identity)
383
- const registered = await registry.register(entry, revision)
384
- if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
385
- const latest = await registry.read()
386
- return reconcileOne(entry.identity, latest.servers[entry.identity], token)
641
+ try {
642
+ const current = await registry.read()
643
+ const revision = current.kind === 'ok' ? current.revision : 0
644
+ const registered = await registry.register(entry, revision)
645
+ if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
646
+ const latest = await registry.read()
647
+ return reconcileOne(entry.identity, latest.servers[entry.identity], token)
648
+ } catch (cause) {
649
+ abortOperation(token)
650
+ throw cause
651
+ }
387
652
  })
388
653
  }
389
654
 
390
655
  function agentCreated(payload) {
656
+ const agent = rememberAgent(payload?.agent)
657
+ if (!agent) return
658
+ // Creation remains non-throwing for visibility failures; admission is an
659
+ // independent hard gate. Registration itself uses the throwing path so
660
+ // its facade transaction can roll back.
661
+ try { reconcileAgent(agent) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
662
+ }
663
+ function agentDisposed(payload) {
391
664
  const agent = payload?.agent
392
- if (publicNames.size === 0) return
393
- const deny = [...publicNames.entries()].filter(([, owner]) => !gate.enabledFor(agent, typeof owner === 'string' ? owner : owner.identity)).map(([name]) => name)
394
- if (deny.length > 0) {
395
- try { agent?.ctx?.tools?.restrict?.({ deny }) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
396
- }
665
+ if (!isAgent(agent)) return
666
+ // Object identity is load-bearing: a delayed disposal from an old
667
+ // incarnation must never clean the replacement agent of the same session.
668
+ seenAgents.delete(agent)
669
+ disposeAgentRestrictions(agent)
397
670
  }
398
671
  ctx.on?.('agent/created', payload => { agentCreated(payload) })
672
+ ctx.on?.('agent/disposed', payload => { agentDisposed(payload) })
399
673
 
400
- async function list() {
401
- const current = await registry.read()
402
- const rows = listRows()
403
- const partitioned = partitionMcpRows(rows, mount.mounted, current.kind === 'ok' ? current.servers : {})
404
- if (current.kind === 'absent') return { managed: [], unmanaged: partitioned.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
405
- if (current.kind !== 'ok') return { managed: [], unmanaged: partitioned.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
406
- const defaults = new Set(current.enabled)
407
- const managed = Object.values(current.servers).map(entry => {
408
- const phase = runtime.snapshot(entry.identity)
409
- const mountedRecord = mount.mounted.get(entry.identity)
410
- const mounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
411
- const holder = hostRowHoldingReservation(rows, entry.client?.serverName ?? entry.identity)
412
- const state = holder ? 'conflict' : mounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
413
- const discovery = discoveryFor(entry.identity, entry.generation)
414
- return {
415
- identity: entry.identity,
416
- label: entry.label,
417
- serverName: entry.client?.serverName ?? entry.identity,
418
- transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
419
- incarnation: entry.incarnation ?? null,
420
- generation: entry.generation,
421
- defaultEnabled: defaults.has(entry.identity),
422
- state,
423
- statusSource: mounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
424
- runtimePhase: phase.phase,
425
- discovery: mounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
426
- lastMount: receipts.mount.get(entry.identity) ?? null,
427
- lastTest: receipts.test.get(entry.identity) ?? null,
428
- startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
429
- liveReconnect: 'unknown',
430
- processExit: 'unknown',
431
- stderr: 'unknown',
432
- readOnly: false,
433
- config: redactMcpEntry(entry),
434
- fingerprint: fingerprintMcpEntry(entry),
435
- }
436
- })
437
- return { managed, unmanaged: partitioned.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
438
- }
674
+ const inventory = createMcpInventoryProjection({
675
+ registry, runtime, mounted: mount.mounted, receipts, listRows, startErrors,
676
+ discoveryFor, support, isResidue: isMcpResidueId, groupPrefix: MCP_GROUP_PREFIX, ownership,
677
+ holdsReservation: hostRowHoldingReservation,
678
+ redactEntry: redactMcpEntry, fingerprintEntry: fingerprintMcpEntry,
679
+ })
680
+ const list = () => inventory.list()
439
681
 
440
682
  const gateFace = {
441
683
  admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
@@ -448,11 +690,11 @@ export function createMcpManager(dependencies = {}) {
448
690
  const face = {
449
691
  start, startErrors, adopt, gate: gateFace, drain, registry, runtime,
450
692
  recoverResidue,
451
- list,
693
+ list, syncSession,
452
694
  publicNames,
453
695
  mounted: mount.mounted,
454
696
  beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
455
- remove: identity => serialize(identity, () => removeRuntime(identity)),
697
+ remove: identity => removeRuntime(identity),
456
698
  /** Run one same-identity operation after every earlier one settled. */
457
699
  serialize,
458
700
  support,
@@ -464,17 +706,27 @@ export function createMcpManager(dependencies = {}) {
464
706
  mcpGroupOptions,
465
707
  }
466
708
  try { ctx.reflect?.provide?.('weirMcpManager', face) } catch { /* older row */ }
709
+ // Bootstrap only after face publication so revision-local agent ownership
710
+ // can resolve the exact manager rather than observing an empty service.
711
+ if (typeof agentsService?.list === 'function') {
712
+ try { liveAgents() } catch (cause) { onError('mcp-agent-bootstrap-failed', cause) }
713
+ }
467
714
  void Promise.resolve().then(() => face.start()).catch(cause => {
468
715
  startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
469
716
  onError('mcp-manager-start-failed', cause)
470
717
  })
471
718
  return () => {
719
+ defaultLifecycleActive = false
472
720
  try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
721
+ // Child-group disposal normally unregisters tools first. If any public
722
+ // fact remains, unregistration was not confirmed: KEEP its deny instead
723
+ // of exposing a still-registered tool during manager teardown.
724
+ for (const agent of [...restrictions.keys()]) disposeAgentRestrictions(agent, { onlyAbsent: true })
473
725
  }
474
726
  }
475
727
  }
476
728
 
477
729
  export const name = 'weir-mcp-manager'
478
- export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources']
730
+ export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources', 'agents']
479
731
  export const apply = createMcpManager()
480
732
  export const __message = message