weir-harness 2.1.1 → 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.
@@ -3,8 +3,16 @@
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import { isSegment } from './store/paths.js'
5
5
  import { McpLifecycleError, mcpDiagnostic } from './mcp-diagnostics.js'
6
+ import {
7
+ MCP_GROUP_PREFIX,
8
+ createMcpMountOwnership,
9
+ isMcpResidueId,
10
+ isSuffixedMcpGroupId,
11
+ resolveMcpGroupIdentity,
12
+ } from './mcp-mount-ownership.js'
13
+
14
+ export { MCP_GROUP_PREFIX, isMcpResidueId } from './mcp-mount-ownership.js'
6
15
 
7
- export const MCP_GROUP_PREFIX = 'weir-mcp-'
8
16
  const DEFAULT_STARTUP_TIMEOUT = 15_000
9
17
  const DEFAULT_CLEANUP_TIMEOUT = 15_000
10
18
  const message = error => (error instanceof Error ? error.message : String(error))
@@ -29,9 +37,10 @@ export function detectMcpNameAmbiguity(entries) {
29
37
  return conflicts.length === 0 ? { kind: 'ok' } : { kind: 'conflict', conflicts }
30
38
  }
31
39
 
32
- export function mcpGroupOptions({ identity, generation, client }) {
40
+ export function mcpGroupOptions({ identity, generation, client, groupId = null }) {
33
41
  if (!isSegment(identity)) throw new TypeError(`invalid MCP server identity: ${JSON.stringify(identity)}`)
34
- const group = `${MCP_GROUP_PREFIX}${identity}`
42
+ const group = groupId ?? `${MCP_GROUP_PREFIX}${identity}`
43
+ if (!isMcpResidueId(group)) throw new TypeError(`invalid actual MCP groupId: ${JSON.stringify(group)}`)
35
44
  return {
36
45
  id: group,
37
46
  name: 'cordis:group',
@@ -44,31 +53,44 @@ export function mcpGroupOptions({ identity, generation, client }) {
44
53
  }
45
54
  }
46
55
 
47
- export const isMcpResidueId = id => typeof id === 'string' && id.startsWith(MCP_GROUP_PREFIX) && !id.endsWith('-facade') && !id.endsWith('-client')
48
-
49
- /**
50
- * The stock EntryTree methods write through their tree. Its root EntryGroup
51
- * methods only mutate the runtime store, which is the non-profile-writing seam
52
- * used for materialized secrets. Any other shape is unsupported, never a
53
- * fallback to the write-through tree.
54
- */
55
- export function resolveEphemeralMcpTree(loader) {
56
- const root = loader?.root
57
- const tree = root?.tree
58
- if (tree?.root !== root || typeof root?.create !== 'function' || typeof root?.remove !== 'function'
59
- || typeof tree?.await !== 'function' || typeof tree?.resolve !== 'function') {
56
+ function nonWritingTree(tree, { scoped = false } = {}) {
57
+ const root = tree?.root
58
+ if (tree?.root?.tree !== tree || typeof root?.create !== 'function' || typeof root?.remove !== 'function'
59
+ || typeof tree?.await !== 'function' || typeof tree?.resolve !== 'function'
60
+ || typeof tree?.entries !== 'function' || !tree?.store || typeof tree.store !== 'object') {
60
61
  return { kind: 'unsupported', reason: 'ephemeral-loader-unavailable' }
61
62
  }
62
63
  return {
63
64
  kind: 'ok',
64
65
  tree: {
65
66
  ephemeral: true,
67
+ scoped,
68
+ source: tree,
69
+ store: tree.store,
66
70
  create: options => root.create(options),
67
71
  remove: id => root.remove(id),
72
+ entries: () => [...tree.entries()],
73
+ resolve: id => tree.resolve(id),
74
+ await: () => tree.await(),
68
75
  },
69
76
  }
70
77
  }
71
78
 
79
+ /** The exact retained-revision seam proven by the real scoped-tree canary. */
80
+ export function resolveScopedMcpTree(tree) {
81
+ return nonWritingTree(tree, { scoped: true })
82
+ }
83
+
84
+ /**
85
+ * The stock EntryTree methods write through their tree. Its root EntryGroup
86
+ * methods only mutate the runtime store, which is the non-profile-writing seam
87
+ * used for materialized secrets. Any other shape is unsupported, never a
88
+ * fallback to the write-through tree.
89
+ */
90
+ export function resolveEphemeralMcpTree(loader) {
91
+ return nonWritingTree(loader?.root?.tree)
92
+ }
93
+
72
94
  export function strictMcpClientConfig(client, { probe = false } = {}) {
73
95
  const reconnect = probe
74
96
  ? { enabled: false, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 1 }
@@ -88,38 +110,134 @@ async function bounded(promise, timeoutMs, code, label) {
88
110
  }
89
111
  }
90
112
 
91
- export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT, cleanupTimeoutMs = DEFAULT_CLEANUP_TIMEOUT, ephemeralTree } = {}) {
113
+ export function createMcpMount({
114
+ loader,
115
+ onError = () => {},
116
+ startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT,
117
+ cleanupTimeoutMs = DEFAULT_CLEANUP_TIMEOUT,
118
+ ephemeralTree,
119
+ ownership = createMcpMountOwnership(),
120
+ } = {}) {
92
121
  /** @type {Map<string, { groupId: string, generation: number, state?: string }>} */
93
122
  const mounted = new Map()
123
+ const holder = {}
124
+ const scopedTrees = new Set()
94
125
 
95
- const treeFor = secretBearing => {
96
- const resolved = ephemeralTree === undefined ? resolveEphemeralMcpTree(loader) : ephemeralTree
97
- if (resolved?.kind === 'ok' && resolved.tree) return resolved.tree
98
- if (secretBearing) throw new McpLifecycleError('unsupported', new Error('secret-bearing MCP mount requires a proven non-profile-writing Loader root group'), { status: 'unsupported', action: 'use-secret-free-config' })
126
+ const legacyTree = () => {
99
127
  if (typeof loader?.create !== 'function' || typeof loader?.remove !== 'function') {
100
128
  throw new McpLifecycleError('unsupported', new Error('MCP Loader lifecycle is unavailable'), { status: 'unsupported' })
101
129
  }
102
- // Compatibility for secret-free legacy records only. New secret-bearing
103
- // paths can never reach this write-through branch.
104
- return { ephemeral: false, create: options => loader.create(options), remove: id => loader.remove(id) }
130
+ return {
131
+ ephemeral: false,
132
+ scoped: false,
133
+ source: loader,
134
+ store: loader.store,
135
+ create: options => loader.create(options),
136
+ remove: id => loader.remove(id),
137
+ entries: typeof loader.entries === 'function' ? () => [...loader.entries()] : null,
138
+ resolve: typeof loader.resolve === 'function' ? id => loader.resolve(id) : null,
139
+ await: typeof loader.await === 'function' ? () => loader.await() : async () => {},
140
+ }
141
+ }
142
+
143
+ const hostTree = () => {
144
+ const resolved = resolveEphemeralMcpTree(loader)
145
+ return resolved.kind === 'ok' ? resolved.tree : legacyTree()
105
146
  }
106
147
 
107
- function entriesFor(groupId) {
108
- if (typeof loader?.entries === 'function') {
109
- return [...loader.entries()].filter(entry => entry?.id === groupId || String(entry?.id ?? '').startsWith(`${groupId}:`))
148
+ const treeFor = secretBearing => {
149
+ // An explicitly retained/revision-scoped tree is mandatory. If its proven
150
+ // non-writing shape is unavailable, fail closed rather than silently
151
+ // reintroducing a shared-root same-identity collision.
152
+ if (ephemeralTree !== undefined && ephemeralTree?.kind !== 'ok') {
153
+ throw new McpLifecycleError('unsupported', new Error('revision-scoped MCP mount requires its proven non-profile-writing PresetTree'), { status: 'unsupported', action: 'recover-residue' })
110
154
  }
111
- if (loader?.store && typeof loader.store === 'object') {
112
- return Object.values(loader.store).filter(entry => entry?.id === groupId || entry?.options?.id === groupId || String(entry?.options?.id ?? '').startsWith(`${groupId}-`))
155
+ const resolved = ephemeralTree === undefined ? resolveEphemeralMcpTree(loader) : ephemeralTree
156
+ if (resolved?.kind === 'ok' && resolved.tree) {
157
+ if (resolved.tree.scoped) scopedTrees.add(resolved.tree)
158
+ return resolved.tree
159
+ }
160
+ if (secretBearing) throw new McpLifecycleError('unsupported', new Error('secret-bearing MCP mount requires a proven non-profile-writing Loader root group'), { status: 'unsupported', action: 'use-secret-free-config' })
161
+ // Compatibility for secret-free legacy records only.
162
+ return legacyTree()
163
+ }
164
+
165
+ function entriesWithTree(tree, groupId) {
166
+ const localId = entry => entry?.options?.id ?? entry?.id
167
+ if (typeof tree?.entries === 'function') {
168
+ return [...tree.entries()].filter(entry => {
169
+ const id = localId(entry)
170
+ return id === groupId || String(id ?? '').startsWith(`${groupId}:`)
171
+ || id === `${groupId}-facade` || id === `${groupId}-client`
172
+ })
173
+ }
174
+ if (tree?.store && typeof tree.store === 'object') {
175
+ return Object.values(tree.store).filter(entry => {
176
+ const id = localId(entry)
177
+ return id === groupId || id === `${groupId}-facade` || id === `${groupId}-client`
178
+ })
113
179
  }
114
180
  return null
115
181
  }
116
182
 
183
+ function treeForGroup(groupId) {
184
+ return ownership.forGroup(groupId)?.tree ?? null
185
+ }
186
+
187
+ function residueTree(groupId) {
188
+ const owned = treeForGroup(groupId)
189
+ if (owned) return owned
190
+ if (!loader) return treeFor(false)
191
+ const host = hostTree()
192
+ const hostEntries = entriesWithTree(host, groupId)
193
+ if (hostEntries?.some(entry => (entry?.options?.id ?? entry?.id) === groupId)) return host
194
+ return treeFor(false)
195
+ }
196
+
197
+ function entriesFor(groupId, tree = null) {
198
+ return entriesWithTree(tree ?? residueTree(groupId), groupId)
199
+ }
200
+
117
201
  function forgetGroup(groupId, generation) {
118
202
  for (const [identity, entry] of mounted) {
119
203
  if (entry.groupId === groupId && (!generation || entry.generation === generation)) mounted.delete(identity)
120
204
  }
121
205
  }
122
206
 
207
+ function track(record, promise) {
208
+ const pending = Promise.resolve(promise)
209
+ if (!record) return pending
210
+ record.pending.add(pending)
211
+ pending.then(() => record.pending.delete(pending), () => record.pending.delete(pending))
212
+ return pending
213
+ }
214
+
215
+ function failedCleanup(groupId, status, cause, record = null) {
216
+ if (record) ownership.transition(record, status)
217
+ return {
218
+ status, removed: false, groupId,
219
+ diagnostic: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }),
220
+ }
221
+ }
222
+
223
+ async function finishCleaned(groupId, context, record, removed) {
224
+ forgetGroup(groupId, context.generation)
225
+ try {
226
+ if (typeof context.afterCleanup === 'function') {
227
+ await context.afterCleanup({
228
+ groupId, identity: context.identity, generation: context.generation,
229
+ probe: context.probe === true, owner: record?.owner,
230
+ })
231
+ }
232
+ } catch (cause) {
233
+ return failedCleanup(groupId, 'cleanup-failed', cause, record)
234
+ }
235
+ if (record && !ownership.release(record, { provenAbsent: true })) {
236
+ return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP mount ownership still has unresolved create/disposal work'), record)
237
+ }
238
+ return { status: 'cleaned', removed, groupId }
239
+ }
240
+
123
241
  /**
124
242
  * Capture and settle group/client/descendant fibers BEFORE removing rows.
125
243
  * remove() is not the barrier: its return does not await async plugin
@@ -127,19 +245,34 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
127
245
  * replacement and leaves enough state for residue recovery.
128
246
  */
129
247
  async function disposeGroup(groupId, context = {}) {
130
- const tree = context.tree ?? treeFor(Boolean(context.secretBearing))
131
- const captured = entriesFor(groupId)
248
+ const record = ownership.forGroup(groupId)
249
+ if (record && record.holder !== holder) {
250
+ return failedCleanup(groupId, 'cleanup-unknown', new Error('cannot dispose a foreign active MCP mount owner'))
251
+ }
252
+ const tree = context.tree ?? record?.tree ?? residueTree(groupId)
253
+ if (record?.pending.size) {
254
+ try {
255
+ await bounded(Promise.allSettled([...record.pending]), cleanupTimeoutMs, 'cleanup-timeout', `MCP pending create/disposal ${groupId}`)
256
+ } catch (cause) {
257
+ return failedCleanup(groupId, 'cleanup-unknown', cause, record)
258
+ }
259
+ if (record.pending.size > 0) return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP create/disposal has not settled'), record)
260
+ }
261
+ const captured = entriesWithTree(tree, groupId)
132
262
  if (captured?.length === 0) {
133
- forgetGroup(groupId, context.generation)
134
- return { status: 'cleaned', removed: false, groupId }
263
+ if (context.requireCapture === true && context.createTimedOut === true && record?.pending.size) {
264
+ return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP create settlement is not proven'), record)
265
+ }
266
+ return finishCleaned(groupId, context, record, false)
135
267
  }
136
- if (captured === null && context.requireCapture === true) {
137
- return { status: 'cleanup-unknown', removed: false, groupId, diagnostic: mcpDiagnostic(new Error('cannot capture Loader fibers for disposal'), { code: 'cleanup-unknown', action: 'recover-residue' }) }
268
+ if (captured === null && (context.requireCapture === true || context.createTimedOut === true)) {
269
+ return failedCleanup(groupId, 'cleanup-unknown', new Error('cannot capture Loader fibers for disposal'), record)
138
270
  }
139
271
  const fibers = (captured ?? []).map(entry => entry?.fiber).filter(Boolean)
140
272
  const disposals = fibers.map((fiber, index) => {
141
273
  try {
142
- return bounded(fiber.dispose?.(), cleanupTimeoutMs, 'cleanup-timeout', `MCP disposal ${groupId}[${index}]`)
274
+ const disposal = track(record, fiber.dispose?.())
275
+ return bounded(disposal, cleanupTimeoutMs, 'cleanup-timeout', `MCP disposal ${groupId}[${index}]`)
143
276
  } catch (cause) {
144
277
  return Promise.reject(cause)
145
278
  }
@@ -147,105 +280,208 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
147
280
  const settled = await Promise.allSettled(disposals)
148
281
  const rejected = settled.find(result => result.status === 'rejected')
149
282
  if (rejected) {
150
- return { status: rejected.reason?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', removed: false, groupId, diagnostic: mcpDiagnostic(rejected.reason, { code: rejected.reason?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', action: 'recover-residue' }) }
283
+ const status = rejected.reason?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
284
+ return failedCleanup(groupId, status, rejected.reason, record)
151
285
  }
152
286
  try {
153
- await bounded(tree.remove(groupId), cleanupTimeoutMs, 'cleanup-timeout', `MCP Loader remove ${groupId}`)
287
+ const removing = track(record, tree.remove(groupId))
288
+ await bounded(removing, cleanupTimeoutMs, 'cleanup-timeout', `MCP Loader remove ${groupId}`)
154
289
  } catch (cause) {
155
- return { status: cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', removed: false, groupId, diagnostic: mcpDiagnostic(cause, { code: cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', action: 'recover-residue' }) }
290
+ const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
291
+ return failedCleanup(groupId, status, cause, record)
156
292
  }
157
- const remaining = entriesFor(groupId)
293
+ const remaining = entriesWithTree(tree, groupId)
158
294
  let closeConfirmed = remaining === null ? true : remaining.length === 0
159
295
  if (typeof context.confirmClosed === 'function') {
160
296
  try {
161
297
  closeConfirmed = closeConfirmed && await bounded(context.confirmClosed({ groupId, entries: captured, fibers }), cleanupTimeoutMs, 'cleanup-timeout', `MCP close confirmation ${groupId}`) === true
162
298
  } catch (cause) {
163
- return { status: 'cleanup-unknown', removed: false, groupId, diagnostic: mcpDiagnostic(cause, { code: 'cleanup-unknown', action: 'recover-residue' }) }
299
+ return failedCleanup(groupId, 'cleanup-unknown', cause, record)
164
300
  }
165
301
  } else if (captured !== null) {
166
302
  closeConfirmed = closeConfirmed && fibers.every(fiber => fiber.uid === null || fiber.state === 4 || fiber.state === 5)
167
303
  }
168
304
  if (!closeConfirmed || remaining === null && context.requireCapture === true) {
169
- return { status: 'cleanup-unknown', removed: false, groupId, diagnostic: mcpDiagnostic(new Error('MCP transport closure or Loader row removal could not be confirmed'), { code: 'cleanup-unknown', action: 'recover-residue' }) }
305
+ return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP transport closure or Loader row removal could not be confirmed'), record)
170
306
  }
171
- forgetGroup(groupId, context.generation)
172
- return { status: 'cleaned', removed: true, groupId }
307
+ return finishCleaned(groupId, context, record, true)
173
308
  }
174
309
 
175
- async function sweepResidue(candidateIds) {
176
- const removed = []
310
+ function identityForGroup(groupId) {
311
+ const entries = entriesFor(groupId)
312
+ const exact = entries?.find(entry => (entry?.options?.id ?? entry?.id) === groupId)
313
+ // The manager's own plugin ID is residue-shaped but is not a Loader group.
314
+ // Never claim/dispose a standalone row merely because its ID has the prefix.
315
+ if (exact && exact.options?.group !== true) return null
316
+ const row = exact ?? { id: groupId }
317
+ return resolveMcpGroupIdentity({ groupId, mounted, ownership, row })
318
+ }
319
+
320
+ async function sweepResidueDetailed(candidateIds, context = {}) {
321
+ const swept = []
322
+ const blocked = []
323
+ const foreign = []
324
+ const unresolved = []
177
325
  for (const id of candidateIds.filter(isMcpResidueId)) {
178
- const cleanup = await disposeGroup(id, { tree: treeFor(false), identity: id.slice(MCP_GROUP_PREFIX.length) })
179
- if (cleanup.status === 'cleaned') removed.push(id)
180
- else onError(`residue-sweep-${cleanup.status}:${id}`, cleanup.diagnostic)
326
+ let record = ownership.forGroup(id)
327
+ if (record && record.holder !== holder) {
328
+ foreign.push(id)
329
+ continue
330
+ }
331
+ const resolved = identityForGroup(id)
332
+ if (!resolved) {
333
+ unresolved.push(id)
334
+ continue
335
+ }
336
+ const tree = record?.tree ?? residueTree(id)
337
+ if (!record) {
338
+ const captured = entriesWithTree(tree, id)
339
+ const activeUnknown = captured?.some(entry => {
340
+ const entryId = entry?.options?.id ?? entry?.id
341
+ if (entryId === id) return false // the container may be active while every child is disabled residue
342
+ const fiber = entry?.fiber
343
+ return fiber && fiber.uid !== null && fiber.state !== 4 && fiber.state !== 5
344
+ })
345
+ // A facade/config row without a module-realm owner may belong to another
346
+ // loaded bundle instance. Active cross-module ownership is unprovable:
347
+ // retain it instead of sweeping a foreign live client.
348
+ if (activeUnknown) {
349
+ foreign.push(id)
350
+ continue
351
+ }
352
+ record = ownership.claimResidue({ identity: resolved.identity, groupId: id, holder, tree })
353
+ }
354
+ if (!record) {
355
+ foreign.push(id)
356
+ continue
357
+ }
358
+ const cleanup = await disposeGroup(id, {
359
+ tree, identity: resolved.identity, secretBearing: false,
360
+ afterCleanup: context.afterCleanup,
361
+ requireCapture: isSuffixedMcpGroupId(id) || tree.scoped === true,
362
+ })
363
+ if (cleanup.status === 'cleaned') swept.push(id)
364
+ else {
365
+ blocked.push(id)
366
+ onError(`residue-sweep-${cleanup.status}:${id}`, cleanup.diagnostic)
367
+ }
181
368
  }
182
- return removed
369
+ return { swept, blocked, foreign, unresolved }
370
+ }
371
+
372
+ async function sweepResidue(candidateIds, context = {}) {
373
+ return (await sweepResidueDetailed(candidateIds, context)).swept
183
374
  }
184
375
 
185
376
  async function mountOne(input) {
186
377
  const { identity, generation, fiberOf, probe = false, secretBearing = false } = input
378
+ if (!isSegment(identity)) throw new TypeError(`invalid MCP server identity: ${JSON.stringify(identity)}`)
187
379
  const previous = mounted.get(identity)
188
380
  if (!probe && (previous?.state === 'cleanup-failed' || previous?.state === 'cleanup-unknown')) {
189
381
  throw new McpLifecycleError(previous.state, new Error(`same-name MCP replacement is blocked while ${identity} is ${previous.state}`), { status: previous.state, action: 'recover-residue' })
190
382
  }
191
- const options = mcpGroupOptions({ identity, generation, client: strictMcpClientConfig(input.client, { probe }) })
192
383
  const tree = treeFor(secretBearing)
384
+ let record = null
385
+ let options = null
193
386
  let bridgeReady = false
387
+ let createStarted = false
194
388
  let cleanup = null
195
389
  let succeeded = false
196
390
  try {
197
- if (typeof input.beforeCreate === 'function') await input.beforeCreate({ groupId: options.id, identity, generation, probe, ephemeral: tree.ephemeral === true })
391
+ record = ownership.claim(identity, {
392
+ holder, tree,
393
+ available: groupId => {
394
+ const existing = entriesWithTree(tree, groupId)
395
+ if (existing?.length) return false
396
+ if (tree.scoped && loader) {
397
+ const hostEntries = entriesWithTree(hostTree(), groupId)
398
+ if (hostEntries?.length) return false
399
+ }
400
+ return true
401
+ },
402
+ })
403
+ options = mcpGroupOptions({ identity, generation, groupId: record.groupId, client: strictMcpClientConfig(input.client, { probe }) })
404
+ const attempt = () => ({ groupId: options.id, identity, generation, probe, ephemeral: tree.ephemeral === true, owner: record.owner })
405
+ if (typeof input.beforeCreate === 'function') await input.beforeCreate(attempt())
198
406
  bridgeReady = true
199
- await bounded(tree.create(options), startupTimeoutMs, 'startup-timeout', `MCP Loader create ${identity}`)
200
- await bounded(loader.await(), startupTimeoutMs, 'startup-timeout', `MCP Loader await ${identity}`)
407
+ // A foreign module realm may have claimed the group while an async
408
+ // beforeCreate callback yielded. Never dispose that row as our residue.
409
+ const collision = entriesWithTree(tree, options.id)
410
+ if (collision?.length) throw Object.assign(new Error(`MCP Loader group ${options.id} was claimed by another owner`), { code: 'duplicate-mcp-group-owner' })
411
+ createStarted = true
412
+ // The claim above occupies the actual ID synchronously before this call;
413
+ // its original promise remains tracked even if the bounded wait times out.
414
+ const creating = track(record, tree.create(options))
415
+ await bounded(creating, startupTimeoutMs, 'startup-timeout', `MCP Loader create ${identity}`)
416
+ await bounded(tree.await(), startupTimeoutMs, 'startup-timeout', `MCP Loader await ${identity}`)
201
417
  const fiber = typeof fiberOf === 'function'
202
- ? await bounded(fiberOf(options.id), startupTimeoutMs, 'startup-timeout', `MCP client readiness ${identity}`)
418
+ ? await bounded(fiberOf(options.id, { tree, owner: record.owner }), startupTimeoutMs, 'startup-timeout', `MCP client readiness ${identity}`)
203
419
  : { state: 'running' }
204
420
  if (fiber === null || fiber === undefined) throw new Error('mcp-mount-error: client entry has no fiber after loader.await()')
205
421
  if (fiber.state !== undefined && !['running', 'active', 2].includes(fiber.state)) throw new Error(`mcp-mount-error: client fiber is not ready (state ${String(fiber.state)})`)
206
- const clientEntry = loader?.store?.[`${options.id}-client`]
422
+ const clientEntry = tree.store?.[`${options.id}-client`]
207
423
  if (clientEntry?.error) throw clientEntry.error
208
- if (typeof input.readback === 'function') await input.readback({ groupId: options.id, identity, generation, probe })
209
- const discovery = typeof input.discovery === 'function' ? await input.discovery({ groupId: options.id, identity, generation, probe }) : { toolCount: null, toolNamesPreview: [], truncated: false }
424
+ if (typeof input.readback === 'function') await input.readback(attempt())
425
+ const discovery = typeof input.discovery === 'function' ? await input.discovery(attempt()) : { toolCount: null, toolNamesPreview: [], truncated: false }
210
426
  const result = { status: 'mounted', groupId: options.id, identity, generation, probe, discovery, execution: input.execution ?? null }
211
427
  if (!probe) mounted.set(identity, { groupId: options.id, generation, state: 'mounted' })
428
+ ownership.transition(record, 'mounted')
212
429
  succeeded = true
213
430
  return result
214
431
  } catch (cause) {
215
- cleanup = await disposeGroup(options.id, { tree, identity, generation, secretBearing, confirmClosed: input.confirmClosed })
216
- if (!probe && cleanup.status !== 'cleaned') mounted.set(identity, { groupId: options.id, generation, state: cleanup.status })
217
- const diagnostic = mcpDiagnostic(cause, { secrets: input.secrets, action: cleanup.status === 'cleaned' ? 'retry' : 'recover-residue' })
218
- throw new McpLifecycleError(cleanup.status === 'cleaned' ? diagnostic.code : cleanup.status, cause, {
219
- status: cleanup.status === 'cleaned' ? (probe ? 'probe-failed' : 'mount-failed') : cleanup.status,
432
+ if (!createStarted && record) {
433
+ cleanup = await finishCleaned(options.id, {
434
+ identity, generation, probe,
435
+ afterCleanup: bridgeReady ? input.afterCleanup : null,
436
+ }, record, false)
437
+ } else if (options) {
438
+ cleanup = await disposeGroup(options.id, {
439
+ tree, identity, generation, probe, secretBearing,
440
+ confirmClosed: input.confirmClosed,
441
+ createTimedOut: cause?.code === 'startup-timeout' && createStarted,
442
+ requireCapture: cause?.code === 'startup-timeout' && createStarted,
443
+ afterCleanup: bridgeReady ? input.afterCleanup : null,
444
+ })
445
+ }
446
+ if (!probe && cleanup && cleanup.status !== 'cleaned') mounted.set(identity, { groupId: options.id, generation, state: cleanup.status })
447
+ const diagnostic = mcpDiagnostic(cause, { secrets: input.secrets, action: cleanup?.status === 'cleaned' ? 'retry' : 'recover-residue' })
448
+ throw new McpLifecycleError(cleanup?.status === 'cleaned' ? diagnostic.code : cleanup?.status ?? 'mount-failed', cause, {
449
+ status: cleanup?.status === 'cleaned' ? (probe ? 'probe-failed' : 'mount-failed') : cleanup?.status ?? 'mount-failed',
220
450
  secrets: input.secrets,
221
451
  cleanup,
222
- action: cleanup.status === 'cleaned' ? 'retry' : 'recover-residue',
452
+ action: cleanup?.status === 'cleaned' ? 'retry' : 'recover-residue',
223
453
  })
224
454
  } finally {
225
- if (!succeeded && bridgeReady && cleanup?.status === 'cleaned' && typeof input.afterCleanup === 'function') await input.afterCleanup({ groupId: options.id, identity, generation, probe })
226
- if (succeeded && probe && typeof input.afterProbeReady === 'function') await input.afterProbeReady({ groupId: options.id, identity, generation })
455
+ if (succeeded && probe && typeof input.afterProbeReady === 'function') await input.afterProbeReady({ groupId: options.id, identity, generation, owner: record.owner })
227
456
  }
228
457
  }
229
458
 
230
459
  async function unmountOne(identity, guards = {}, context = {}) {
231
460
  const entry = mounted.get(identity)
232
- if (!entry && context.required === true) return { status: 'cleanup-unknown', removed: false, reason: 'mount-record-missing' }
233
- const groupId = entry?.groupId ?? `${MCP_GROUP_PREFIX}${identity}`
234
- const cleanup = await disposeGroup(groupId, { ...context, identity, generation: entry?.generation, tree: context.tree ?? treeFor(Boolean(context.secretBearing)) })
461
+ if (!entry) {
462
+ if (context.required === true) return { status: 'cleanup-unknown', removed: false, reason: 'mount-record-missing' }
463
+ // Ordinary cleanup is not residue recovery: without our exact mount record
464
+ // there is no authority to infer and delete a possibly reused legacy ID.
465
+ return { status: 'cleaned', removed: false, groupId: null }
466
+ }
467
+ const groupId = entry.groupId
468
+ const cleanup = await disposeGroup(groupId, {
469
+ ...context, identity, generation: entry.generation,
470
+ tree: context.tree ?? treeForGroup(groupId) ?? treeFor(Boolean(context.secretBearing)),
471
+ })
235
472
  if (cleanup.status !== 'cleaned') {
236
- if (entry) entry.state = cleanup.status
473
+ entry.state = cleanup.status
237
474
  return cleanup
238
475
  }
239
476
  if (typeof guards.alive === 'function' && typeof guards.remount === 'function') {
240
- for (const [other] of mounted) {
241
- const siblingGroup = `${MCP_GROUP_PREFIX}${other}`
242
- if (!guards.alive().includes(siblingGroup)) {
477
+ for (const [other, sibling] of mounted) {
478
+ if (!guards.alive().includes(sibling.groupId)) {
243
479
  onError(`sibling-vanished-after-unmount:${other}`)
244
480
  await guards.remount(other)
245
481
  }
246
482
  }
247
483
  }
248
- return { status: 'cleaned', removed: Boolean(entry) || cleanup.removed, groupId }
484
+ return { status: 'cleaned', removed: true, groupId }
249
485
  }
250
486
 
251
487
  async function probe(input) {
@@ -260,14 +496,27 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
260
496
  if (cause instanceof McpLifecycleError) throw cause
261
497
  throw new McpLifecycleError('probe-failed', cause, { status: 'probe-failed', secrets: input.secrets })
262
498
  }
263
- try {
264
- const cleanup = await disposeGroup(ready.groupId, { tree: treeFor(Boolean(input.secretBearing)), identity, generation: ready.generation, secretBearing: Boolean(input.secretBearing), confirmClosed: input.confirmClosed, requireCapture: true })
265
- if (cleanup.status !== 'cleaned') return { status: cleanup.status, identity, generation: ready.generation, cleanup, diagnostic: cleanup.diagnostic }
266
- if (typeof input.afterCleanup === 'function') await input.afterCleanup({ groupId: ready.groupId, identity, generation: ready.generation, probe: true, cleanup })
267
- return { ...ready, status: 'mounted', cleanup }
268
- } catch (cause) {
269
- throw new McpLifecycleError('cleanup-failed', cause, { status: 'cleanup-failed', secrets: input.secrets, action: 'recover-residue' })
499
+ const record = ownership.forGroup(ready.groupId)
500
+ const cleanup = await disposeGroup(ready.groupId, {
501
+ tree: record?.tree ?? treeFor(Boolean(input.secretBearing)), identity, generation: ready.generation,
502
+ probe: true, secretBearing: Boolean(input.secretBearing), confirmClosed: input.confirmClosed,
503
+ requireCapture: true, afterCleanup: input.afterCleanup,
504
+ })
505
+ if (cleanup.status !== 'cleaned') return { status: cleanup.status, identity, generation: ready.generation, cleanup, diagnostic: cleanup.diagnostic }
506
+ return { ...ready, status: 'mounted', cleanup }
507
+ }
508
+
509
+ function listRows() {
510
+ const rows = []
511
+ for (const tree of scopedTrees) {
512
+ for (const entry of tree.entries()) {
513
+ const options = entry?.options ?? {}
514
+ const id = options.id ?? entry?.id
515
+ if (!isMcpResidueId(id) || options.group !== true) continue
516
+ rows.push({ id, name: options.name ?? null, config: options.config ?? {} })
517
+ }
270
518
  }
519
+ return rows
271
520
  }
272
521
 
273
522
  const queues = new Map()
@@ -282,5 +531,8 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
282
531
  const mount = input => input?.probe ? mountOne(input) : serialize(input.identity, () => mountOne(input))
283
532
  const unmount = (identity, guards = {}, context = {}) => serialize(identity, () => unmountOne(identity, guards, context))
284
533
 
285
- return { mount, unmount, probe, sweepResidue, disposeGroup, mounted, entriesFor, message }
534
+ return {
535
+ mount, unmount, probe, sweepResidue, sweepResidueDetailed, disposeGroup,
536
+ mounted, entriesFor, identityForGroup, listRows, treeForGroup, ownership, message,
537
+ }
286
538
  }
@@ -0,0 +1,76 @@
1
+ // One manager-owned Operation Coordination module. The per-identity FIFO,
2
+ // runtime fence/token and admission-drain transitions are inseparable here;
3
+ // mount/probe/registry/CAS work remains in their existing modules.
4
+ // The independent createMcpMount Disposal Barrier deliberately stays inner.
5
+
6
+ /**
7
+ * Per-identity FIFO for operations that open a runtime fence. A rejected task
8
+ * does not poison its identity, and identities have independent tails. The
9
+ * queue is not reentrant: a caller already in a slot passes its fence token to
10
+ * the inner manager operation instead of serializing the same identity again.
11
+ */
12
+ export function createMcpIdentityLock() {
13
+ const tails = new Map()
14
+ return function serialize(identity, task) {
15
+ const previous = tails.get(identity) ?? Promise.resolve()
16
+ const run = previous.then(() => task())
17
+ const tail = run.then(() => {}, () => {})
18
+ tails.set(identity, tail)
19
+ void tail.then(() => { if (tails.get(identity) === tail) tails.delete(identity) })
20
+ return run
21
+ }
22
+ }
23
+
24
+ const fallbackLocks = new WeakMap()
25
+ /**
26
+ * Resolve the manager's own queue. The WeakMap fallback only supports legacy
27
+ * test/older faces without serialize; a real manager always exposes its one
28
+ * coordinator queue so this adapter cannot silently fork production ordering.
29
+ */
30
+ export function mcpIdentityLockFor(manager) {
31
+ if (typeof manager?.serialize === 'function') return (identity, task) => manager.serialize(identity, task)
32
+ if (!manager || (typeof manager !== 'object' && typeof manager !== 'function')) return createMcpIdentityLock()
33
+ let lock = fallbackLocks.get(manager)
34
+ if (!lock) { lock = createMcpIdentityLock(); fallbackLocks.set(manager, lock) }
35
+ return lock
36
+ }
37
+
38
+ /**
39
+ * @param {{
40
+ * runtime: ReturnType<typeof import('./mcp-runtime.js').createMcpRuntime>,
41
+ * drain: { closeAll: (identity: string) => void, reopenAll: (identity: string) => void },
42
+ * transition?: <T>(identity: string, change: () => T) => T,
43
+ * }} options
44
+ */
45
+ export function createMcpOperationCoordinator({ runtime, drain, transition = (_identity, change) => change() }) {
46
+ const serialize = createMcpIdentityLock()
47
+
48
+ function beginOperation(identity, phase = 'replacing', details = {}) {
49
+ return transition(identity, () => {
50
+ // Both transitions are synchronous and precede every async operation
51
+ // step. No registry, credential or probe await can precede this fence.
52
+ const token = runtime.begin(identity, { phase, ...details })
53
+ drain.closeAll(identity)
54
+ return token
55
+ })
56
+ }
57
+
58
+ function finishOperation(token, phase, details = {}) {
59
+ const finished = transition(token.identity, () => runtime.finish(token, phase, details))
60
+ // A stale finish cannot report mounted through runtime.finish and must not
61
+ // reopen admission for whichever operation now owns the identity.
62
+ if (finished && phase === 'mounted') drain.reopenAll(token.identity)
63
+ return finished
64
+ }
65
+
66
+ function abortOperation(token) {
67
+ const previousPhase = token.previous?.phase
68
+ return transition(token.identity, () => {
69
+ const restored = runtime.restore(token)
70
+ if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
71
+ return restored
72
+ })
73
+ }
74
+
75
+ return { serialize, beginOperation, finishOperation, abortOperation }
76
+ }