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