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.
- package/package.json +1 -1
- package/src/capabilities/apply-engine.js +20 -4
- package/src/capabilities/lifecycle-feed.js +47 -0
- package/src/capabilities/mcp-drain.js +6 -0
- package/src/capabilities/mcp-facade-plugin.js +14 -4
- package/src/capabilities/mcp-facade.js +1 -1
- package/src/capabilities/mcp-inventory.js +152 -0
- package/src/capabilities/mcp-manager.js +220 -171
- package/src/capabilities/mcp-mount-ownership.js +114 -0
- package/src/capabilities/mcp-mount.js +334 -82
- package/src/capabilities/mcp-operation-coordinator.js +76 -0
- package/src/capabilities/mcp-runtime.js +3 -29
- package/src/capabilities/skill-selection-plugin.js +8 -0
- package/src/file-lease/client.js +61 -3
- package/src/file-lease/negotiation.js +89 -0
- package/src/file-lease/plugin.js +25 -23
- package/src/file-lease/tools.js +71 -121
- package/src/file-lease/watch.js +225 -36
|
@@ -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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
55
|
-
const
|
|
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
|
|
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 =
|
|
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({
|
|
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 = {
|
|
@@ -92,9 +100,13 @@ export function createMcpManager(dependencies = {}) {
|
|
|
92
100
|
const restrictions = new Map() // agent → Map(publicName → { owner, dispose })
|
|
93
101
|
|
|
94
102
|
const identityOf = owner => typeof owner === 'string' ? owner : owner?.identity
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|
|
98
110
|
const isAgent = agent => (typeof agent === 'object' && agent !== null) || typeof agent === 'function'
|
|
99
111
|
function rememberAgent(agent) {
|
|
100
112
|
if (!isAgent(agent)) return null
|
|
@@ -125,7 +137,12 @@ export function createMcpManager(dependencies = {}) {
|
|
|
125
137
|
const present = publicNames.has(name)
|
|
126
138
|
const owner = publicNames.get(name)
|
|
127
139
|
const existing = held?.get(name)
|
|
128
|
-
const
|
|
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))
|
|
129
146
|
if (shouldDeny && !existing) {
|
|
130
147
|
const restrict = agent?.ctx?.tools?.restrict
|
|
131
148
|
if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
|
|
@@ -170,10 +187,14 @@ export function createMcpManager(dependencies = {}) {
|
|
|
170
187
|
if (rememberAgent(agent)) reconcileAgent(agent)
|
|
171
188
|
}
|
|
172
189
|
|
|
173
|
-
function registerPublicName(name, identity, generation, captureRollback) {
|
|
190
|
+
function registerPublicName(name, identity, generation, captureRollback, attempt = null, definition = null) {
|
|
174
191
|
const hadPrevious = publicNames.has(name)
|
|
175
192
|
const previous = publicNames.get(name)
|
|
176
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)
|
|
177
198
|
const previousDenies = []
|
|
178
199
|
for (const [agent, held] of restrictions) {
|
|
179
200
|
const record = held.get(name)
|
|
@@ -228,8 +249,8 @@ export function createMcpManager(dependencies = {}) {
|
|
|
228
249
|
syncName(name, added)
|
|
229
250
|
}
|
|
230
251
|
|
|
231
|
-
function unregisterPublicName(name, identity, generation) {
|
|
232
|
-
if (!sameOwner(publicNames.get(name), identity, generation)) return false
|
|
252
|
+
function unregisterPublicName(name, identity, generation, attempt = null) {
|
|
253
|
+
if (!sameOwner(publicNames.get(name), identity, generation, attempt)) return false
|
|
233
254
|
publicNames.delete(name)
|
|
234
255
|
try { syncName(name) } catch (cause) { onError(`mcp-schema-cleanup-failed:${name}`, cause) }
|
|
235
256
|
return true
|
|
@@ -251,37 +272,41 @@ export function createMcpManager(dependencies = {}) {
|
|
|
251
272
|
if (held.size === 0) restrictions.delete(agent)
|
|
252
273
|
}
|
|
253
274
|
|
|
254
|
-
|
|
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) {
|
|
275
|
+
function releaseBridge(groupId, expected) {
|
|
261
276
|
const bridge = bridges.get(groupId)
|
|
262
|
-
if (!bridge) return
|
|
263
|
-
if (expected && (bridge.identity !== expected.identity || bridge.generation !== expected.generation)) return
|
|
277
|
+
if (!bridge || !expected || bridge !== expected) return false
|
|
264
278
|
bridges.delete(groupId)
|
|
265
279
|
for (const [name, owner] of [...publicNames]) {
|
|
266
|
-
if (sameOwner(owner, bridge.identity, bridge.generation)) unregisterPublicName(name, bridge.identity, bridge.generation)
|
|
280
|
+
if (sameOwner(owner, bridge.identity, bridge.generation, bridge)) unregisterPublicName(name, bridge.identity, bridge.generation, bridge)
|
|
267
281
|
}
|
|
268
282
|
bridge.release()
|
|
283
|
+
bridge.definitions.clear()
|
|
284
|
+
return true
|
|
269
285
|
}
|
|
270
|
-
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() }
|
|
271
289
|
const release = registerMcpFacadeBridge(groupId, {
|
|
272
290
|
host, identity, generation,
|
|
273
291
|
admit: agent => gate.permit(agent, identity, generation),
|
|
274
292
|
assertPermit: gate.assertPermit,
|
|
275
293
|
isEnabledFor: agent => gate.enabledFor(agent, identity),
|
|
276
294
|
drain,
|
|
277
|
-
onRegister: (name, captureRollback) => registerPublicName(name, identity, generation, captureRollback),
|
|
278
|
-
onUnregister: name => unregisterPublicName(name, identity, generation),
|
|
295
|
+
onRegister: (name, captureRollback, definition) => registerPublicName(name, identity, generation, captureRollback, record, definition),
|
|
296
|
+
onUnregister: name => unregisterPublicName(name, identity, generation, record),
|
|
279
297
|
onError,
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
298
|
+
}, owner ?? record)
|
|
299
|
+
record.release = release
|
|
300
|
+
bridges.set(groupId, record)
|
|
301
|
+
return record
|
|
302
|
+
}
|
|
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()
|
|
285
310
|
return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
|
|
286
311
|
}
|
|
287
312
|
|
|
@@ -297,24 +322,6 @@ export function createMcpManager(dependencies = {}) {
|
|
|
297
322
|
}
|
|
298
323
|
return result
|
|
299
324
|
}
|
|
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
325
|
async function waitForDrain(identity) {
|
|
319
326
|
const result = await drain.drainAll(identity)
|
|
320
327
|
if (!result.drained) {
|
|
@@ -349,8 +356,11 @@ export function createMcpManager(dependencies = {}) {
|
|
|
349
356
|
return (await processAdapter.prepare(client)).execution
|
|
350
357
|
}
|
|
351
358
|
|
|
352
|
-
|
|
353
|
-
|
|
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) {
|
|
354
364
|
try {
|
|
355
365
|
const current = await registry.read()
|
|
356
366
|
const registered = current.kind === 'ok' ? current.servers[identity] : null
|
|
@@ -361,15 +371,20 @@ export function createMcpManager(dependencies = {}) {
|
|
|
361
371
|
}
|
|
362
372
|
await waitForDrain(identity)
|
|
363
373
|
if (mount.mounted.has(identity)) {
|
|
364
|
-
const
|
|
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 })
|
|
365
378
|
if (removed.status !== 'cleaned') {
|
|
366
379
|
finishOperation(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
|
|
367
380
|
return { status: removed.status, identity, cleanup: removed, saved: true }
|
|
368
381
|
}
|
|
369
|
-
|
|
382
|
+
// Legacy mount doubles do not consume context; exact compare-delete
|
|
383
|
+
// makes this compatibility fallback harmless for the real mount.
|
|
384
|
+
afterCleanup()
|
|
370
385
|
}
|
|
371
386
|
const prepared = await prepare(entry)
|
|
372
|
-
|
|
387
|
+
let attempt = null
|
|
373
388
|
const result = await mount.mount({
|
|
374
389
|
identity,
|
|
375
390
|
generation: entry.generation,
|
|
@@ -378,9 +393,13 @@ export function createMcpManager(dependencies = {}) {
|
|
|
378
393
|
secretBearing: prepared.secretBearing,
|
|
379
394
|
execution: prepared.execution,
|
|
380
395
|
fiberOf,
|
|
381
|
-
beforeCreate: () =>
|
|
382
|
-
|
|
383
|
-
|
|
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),
|
|
384
403
|
readback: async () => {
|
|
385
404
|
const readback = await registry.read()
|
|
386
405
|
const actual = readback.kind === 'ok' ? readback.servers[identity] : null
|
|
@@ -396,7 +415,6 @@ export function createMcpManager(dependencies = {}) {
|
|
|
396
415
|
receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
|
|
397
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' } }
|
|
398
417
|
}
|
|
399
|
-
drain.reopenAll(identity)
|
|
400
418
|
clearStartErrors(identity)
|
|
401
419
|
const receipt = { time: now(), status: 'mounted', generation: entry.generation, discovery: result.discovery }
|
|
402
420
|
receipts.mount.set(identity, receipt)
|
|
@@ -413,16 +431,23 @@ export function createMcpManager(dependencies = {}) {
|
|
|
413
431
|
}
|
|
414
432
|
}
|
|
415
433
|
|
|
416
|
-
|
|
417
|
-
|
|
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) {
|
|
418
439
|
try {
|
|
419
440
|
await waitForDrain(identity)
|
|
420
|
-
const
|
|
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 })
|
|
421
446
|
if (cleanup.status !== 'cleaned') {
|
|
422
447
|
finishOperation(token, cleanup.status)
|
|
423
448
|
return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
|
|
424
449
|
}
|
|
425
|
-
|
|
450
|
+
afterCleanup()
|
|
426
451
|
finishOperation(token, 'unknown', { generation: null, incarnation: null })
|
|
427
452
|
return { status: 'removed', identity, removed: true }
|
|
428
453
|
} catch (cause) {
|
|
@@ -433,6 +458,7 @@ export function createMcpManager(dependencies = {}) {
|
|
|
433
458
|
}
|
|
434
459
|
|
|
435
460
|
async function probe(entry, { targetIdentity = entry.identity } = {}) {
|
|
461
|
+
let attempt = null
|
|
436
462
|
try {
|
|
437
463
|
const prepared = await prepare(entry)
|
|
438
464
|
const result = await mount.probe({
|
|
@@ -442,9 +468,13 @@ export function createMcpManager(dependencies = {}) {
|
|
|
442
468
|
secretBearing: prepared.secretBearing,
|
|
443
469
|
secrets: prepared.secrets,
|
|
444
470
|
fiberOf,
|
|
445
|
-
beforeCreate: (
|
|
446
|
-
|
|
447
|
-
|
|
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),
|
|
448
478
|
})
|
|
449
479
|
const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
|
|
450
480
|
// Receipts share one shape with lastMount: the row reads `diagnostic`.
|
|
@@ -475,60 +505,100 @@ export function createMcpManager(dependencies = {}) {
|
|
|
475
505
|
}
|
|
476
506
|
let mountedCount = 0
|
|
477
507
|
for (const entry of Object.values(current.servers)) {
|
|
478
|
-
const result = await
|
|
508
|
+
const result = await reconcileOne(entry.identity, entry)
|
|
479
509
|
if (result.status === 'mounted') mountedCount += 1
|
|
480
510
|
else if (result.status !== 'saved-degraded') startErrors.push({ identity: entry.identity, reason: result.status })
|
|
481
511
|
}
|
|
482
512
|
return { mounted: mountedCount }
|
|
483
513
|
|
|
484
514
|
}
|
|
485
|
-
const healthy = identity
|
|
515
|
+
const healthy = (identity, state = runtime.snapshot(identity)) => state?.phase === 'mounted' && mount.mounted.has(identity)
|
|
486
516
|
&& (mount.mounted.get(identity)?.state ?? 'mounted') === 'mounted'
|
|
487
517
|
|
|
488
|
-
|
|
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. */
|
|
489
544
|
function recoverOne(identity, assertOwner) {
|
|
490
545
|
return serialize(identity, async () => {
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
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.
|
|
494
548
|
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
549
|
const token = beginOperation(identity)
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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' } }
|
|
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 }
|
|
520
557
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
}
|
|
529
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
|
|
530
601
|
}
|
|
531
|
-
return reconcileOne(identity, entry, token)
|
|
532
602
|
})
|
|
533
603
|
}
|
|
534
604
|
|
|
@@ -544,7 +614,7 @@ export function createMcpManager(dependencies = {}) {
|
|
|
544
614
|
const current = await registry.read()
|
|
545
615
|
if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', skipped: current.kind, results: [] }
|
|
546
616
|
const servers = current.kind === 'ok' ? current.servers : {}
|
|
547
|
-
const orphans = listEntryIds().filter(isMcpResidueId).map(
|
|
617
|
+
const orphans = listEntryIds().filter(isMcpResidueId).map(residueIdentity).filter(id => id && !servers[id])
|
|
548
618
|
let candidates = [...new Set([...Object.keys(servers).filter(id => !healthy(id)), ...orphans])]
|
|
549
619
|
if (identity !== null) candidates = candidates.filter(id => id === identity)
|
|
550
620
|
const results = []
|
|
@@ -567,13 +637,18 @@ export function createMcpManager(dependencies = {}) {
|
|
|
567
637
|
const holder = hostRowHoldingReservation(listRows(), entry.client?.serverName ?? entry.transport?.serverName ?? entry.identity)
|
|
568
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.` }
|
|
569
639
|
return serialize(entry.identity, async () => {
|
|
570
|
-
const current = await registry.read()
|
|
571
|
-
const revision = current.kind === 'ok' ? current.revision : 0
|
|
572
640
|
const token = beginOperation(entry.identity)
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
}
|
|
577
652
|
})
|
|
578
653
|
}
|
|
579
654
|
|
|
@@ -596,45 +671,13 @@ export function createMcpManager(dependencies = {}) {
|
|
|
596
671
|
ctx.on?.('agent/created', payload => { agentCreated(payload) })
|
|
597
672
|
ctx.on?.('agent/disposed', payload => { agentDisposed(payload) })
|
|
598
673
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
}
|
|
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()
|
|
638
681
|
|
|
639
682
|
const gateFace = {
|
|
640
683
|
admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
|
|
@@ -651,7 +694,7 @@ export function createMcpManager(dependencies = {}) {
|
|
|
651
694
|
publicNames,
|
|
652
695
|
mounted: mount.mounted,
|
|
653
696
|
beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
|
|
654
|
-
remove: identity =>
|
|
697
|
+
remove: identity => removeRuntime(identity),
|
|
655
698
|
/** Run one same-identity operation after every earlier one settled. */
|
|
656
699
|
serialize,
|
|
657
700
|
support,
|
|
@@ -663,11 +706,17 @@ export function createMcpManager(dependencies = {}) {
|
|
|
663
706
|
mcpGroupOptions,
|
|
664
707
|
}
|
|
665
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
|
+
}
|
|
666
714
|
void Promise.resolve().then(() => face.start()).catch(cause => {
|
|
667
715
|
startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
|
|
668
716
|
onError('mcp-manager-start-failed', cause)
|
|
669
717
|
})
|
|
670
718
|
return () => {
|
|
719
|
+
defaultLifecycleActive = false
|
|
671
720
|
try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
|
|
672
721
|
// Child-group disposal normally unregisters tools first. If any public
|
|
673
722
|
// fact remains, unregistration was not confirmed: KEEP its deny instead
|