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.
- 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 +38 -7
- package/src/capabilities/mcp-inventory.js +152 -0
- package/src/capabilities/mcp-manager.js +413 -161
- package/src/capabilities/mcp-mount-ownership.js +114 -0
- package/src/capabilities/mcp-mount.js +335 -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 +17 -1
- 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 = {
|
|
@@ -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
|
|
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 =>
|
|
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
|
-
|
|
298
|
+
}, owner ?? record)
|
|
299
|
+
record.release = release
|
|
300
|
+
bridges.set(groupId, record)
|
|
301
|
+
return record
|
|
111
302
|
}
|
|
112
|
-
function discoveryFor(identity, generation) {
|
|
113
|
-
const
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
192
|
-
|
|
193
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
447
|
+
finishOperation(token, cleanup.status)
|
|
233
448
|
return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
|
|
234
449
|
}
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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: (
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
302
|
-
//
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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(
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 =>
|
|
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
|