weir-harness 1.2.3 → 2.1.0
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/cordis.patch.yml +7 -18
- 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-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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 +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -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 +161 -56
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -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,236 @@ 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
|
}
|
|
117
90
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
91
|
+
function releaseBridge(groupId) {
|
|
92
|
+
const bridge = bridges.get(groupId)
|
|
93
|
+
if (!bridge) return
|
|
94
|
+
bridges.delete(groupId)
|
|
95
|
+
for (const [name, owner] of publicNames) {
|
|
96
|
+
if (owner.identity === bridge.identity && owner.generation === bridge.generation) publicNames.delete(name)
|
|
97
|
+
}
|
|
98
|
+
bridge.release()
|
|
123
99
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
async function mountOne(identity, entry) {
|
|
127
|
-
const generation = entry.generation
|
|
128
|
-
const groupId = `${MCP_GROUP_PREFIX}${identity}`
|
|
100
|
+
function registerBridge(groupId, identity, generation) {
|
|
129
101
|
const release = registerMcpFacadeBridge(groupId, {
|
|
130
|
-
host,
|
|
131
|
-
identity,
|
|
132
|
-
|
|
133
|
-
admit: agent => gate.admit(agent, identity, generation),
|
|
102
|
+
host, identity, generation,
|
|
103
|
+
admit: agent => gate.permit(agent, identity, generation),
|
|
104
|
+
assertPermit: gate.assertPermit,
|
|
134
105
|
isEnabledFor: agent => gate.enabledFor(agent, identity),
|
|
135
106
|
drain,
|
|
136
|
-
onRegister: name => publicNames.set(name, identity),
|
|
107
|
+
onRegister: name => publicNames.set(name, { identity, generation }),
|
|
137
108
|
onError,
|
|
138
109
|
})
|
|
110
|
+
bridges.set(groupId, { identity, generation, release })
|
|
111
|
+
}
|
|
112
|
+
function discoveryFor(identity, generation) {
|
|
113
|
+
const names = [...publicNames.entries()].filter(([, owner]) => owner.identity === identity && owner.generation === generation).map(([name]) => name).sort()
|
|
114
|
+
return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function beginOperation(identity, phase = 'replacing', details = {}) {
|
|
118
|
+
const token = runtime.begin(identity, { phase, ...details })
|
|
119
|
+
drain.closeAll(identity)
|
|
120
|
+
return token
|
|
121
|
+
}
|
|
122
|
+
function abortOperation(token) {
|
|
123
|
+
const previousPhase = token.previous?.phase
|
|
124
|
+
const restored = runtime.restore(token)
|
|
125
|
+
if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
|
|
126
|
+
return restored
|
|
127
|
+
}
|
|
128
|
+
async function waitForDrain(identity) {
|
|
129
|
+
const result = await drain.drainAll(identity)
|
|
130
|
+
if (!result.drained) {
|
|
131
|
+
const error = new Error(`MCP server "${identity}" still has ${result.inFlight} admitted call(s); replacement cannot prove quiescence`)
|
|
132
|
+
error.code = 'cleanup-unknown'
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function prepare(entry) {
|
|
138
|
+
const credentials = credentialsFor()
|
|
139
|
+
const secretBearing = hasMcpSecrets(entry)
|
|
140
|
+
if (Object.keys(entry?.secretRefs ?? {}).length > 0 && typeof credentials?.resolve !== 'function') {
|
|
141
|
+
const error = new Error('secret-bearing MCP materialization requires DSH credentials')
|
|
142
|
+
error.code = 'unsupported'
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
const materialized = await materializeMcpClientConfig(entry, credentials)
|
|
146
|
+
const prepared = await processAdapter.prepare(materialized.client)
|
|
147
|
+
return { ...prepared, secrets: materialized.resolvedSecrets, secretBearing }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resolve only non-secret execution metadata for an exact confirmation card. */
|
|
151
|
+
async function describeExecution(entry) {
|
|
152
|
+
if (entry?.client?.transport !== 'stdio') return null
|
|
153
|
+
const secretArgs = new Set(Object.keys(entry?.secretRefs?.args ?? {}).map(Number))
|
|
154
|
+
const client = {
|
|
155
|
+
...entry.client,
|
|
156
|
+
args: (entry.client.args ?? []).map((value, index) => secretArgs.has(index) ? '' : value),
|
|
157
|
+
env: {},
|
|
158
|
+
}
|
|
159
|
+
return (await processAdapter.prepare(client)).execution
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function reconcileOne(identity, entry, suppliedToken = null) {
|
|
163
|
+
const token = suppliedToken ?? beginOperation(identity)
|
|
139
164
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
165
|
+
const current = await registry.read()
|
|
166
|
+
const registered = current.kind === 'ok' ? current.servers[identity] : null
|
|
167
|
+
if (!registered || registered.generation !== entry.generation || (registered.incarnation ?? null) !== (entry.incarnation ?? null)) {
|
|
168
|
+
const error = new Error(`MCP registry/mount readback rejected ${identity} generation ${entry.generation}`)
|
|
169
|
+
error.code = 'generation-readback-failed'
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
await waitForDrain(identity)
|
|
173
|
+
if (mount.mounted.has(identity)) {
|
|
174
|
+
const removed = await mount.unmount(identity)
|
|
175
|
+
if (removed.status !== 'cleaned') {
|
|
176
|
+
runtime.finish(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
|
|
177
|
+
return { status: removed.status, identity, cleanup: removed, saved: true }
|
|
178
|
+
}
|
|
179
|
+
releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
|
|
147
180
|
}
|
|
181
|
+
const prepared = await prepare(entry)
|
|
182
|
+
const groupId = `${MCP_GROUP_PREFIX}${identity}`
|
|
183
|
+
const result = await mount.mount({
|
|
184
|
+
identity,
|
|
185
|
+
generation: entry.generation,
|
|
186
|
+
client: prepared.client,
|
|
187
|
+
secrets: prepared.secrets,
|
|
188
|
+
secretBearing: prepared.secretBearing,
|
|
189
|
+
execution: prepared.execution,
|
|
190
|
+
fiberOf,
|
|
191
|
+
beforeCreate: () => registerBridge(groupId, identity, entry.generation),
|
|
192
|
+
afterCleanup: () => releaseBridge(groupId),
|
|
193
|
+
discovery: () => discoveryFor(identity, entry.generation),
|
|
194
|
+
readback: async () => {
|
|
195
|
+
const readback = await registry.read()
|
|
196
|
+
const actual = readback.kind === 'ok' ? readback.servers[identity] : null
|
|
197
|
+
if (!actual || actual.generation !== entry.generation || (actual.incarnation ?? null) !== (entry.incarnation ?? null)) throw new Error('registry generation/incarnation changed during mount')
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
const observed = mount.mounted.get(identity)
|
|
201
|
+
if (!observed || observed.generation !== entry.generation) throw new Error('mounted generation readback failed')
|
|
202
|
+
// Only the fence owner may report mounted and reopen admission: a
|
|
203
|
+
// superseded token means another operation now owns this identity.
|
|
204
|
+
if (!runtime.finish(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
|
|
205
|
+
const diagnostic = mcpDiagnostic(new Error(`MCP operation on ${identity} was superseded before it could report mounted`), { code: 'operation-superseded', action: 'reread-registry' })
|
|
206
|
+
receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
|
|
207
|
+
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
|
+
}
|
|
209
|
+
drain.reopenAll(identity)
|
|
210
|
+
clearStartErrors(identity)
|
|
211
|
+
const receipt = { time: now(), status: 'mounted', generation: entry.generation, discovery: result.discovery }
|
|
212
|
+
receipts.mount.set(identity, receipt)
|
|
213
|
+
return { status: 'mounted', identity, generation: entry.generation, incarnation: entry.incarnation ?? null, mount: { state: 'mounted', observedGeneration: observed.generation }, discovery: result.discovery, execution: result.execution ?? null }
|
|
214
|
+
} catch (cause) {
|
|
215
|
+
const cleanupStatus = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : null
|
|
216
|
+
const status = cleanupStatus ?? (cause?.code === 'unsupported' ? 'unsupported' : 'saved-degraded')
|
|
217
|
+
runtime.finish(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
|
|
218
|
+
const diagnostic = mcpDiagnostic(cause, { code: cause?.code, action: cleanupStatus ? 'recover-residue' : 'retry' })
|
|
219
|
+
receipts.mount.set(identity, { time: now(), status, diagnostic })
|
|
220
|
+
startErrors.push({ identity, reason: diagnostic.message })
|
|
221
|
+
onError(`mcp-mount-${status}:${identity}`, cause)
|
|
222
|
+
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' } }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function removeRuntime(identity, suppliedToken = null) {
|
|
227
|
+
const token = suppliedToken ?? beginOperation(identity, 'removing')
|
|
228
|
+
try {
|
|
229
|
+
await waitForDrain(identity)
|
|
230
|
+
const cleanup = await mount.unmount(identity)
|
|
231
|
+
if (cleanup.status !== 'cleaned') {
|
|
232
|
+
runtime.finish(token, cleanup.status)
|
|
233
|
+
return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
|
|
234
|
+
}
|
|
235
|
+
releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
|
|
236
|
+
runtime.finish(token, 'unknown', { generation: null, incarnation: null })
|
|
237
|
+
return { status: 'removed', identity, removed: true }
|
|
238
|
+
} catch (cause) {
|
|
239
|
+
const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
|
|
240
|
+
runtime.finish(token, status)
|
|
241
|
+
return { status, identity, error: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }), next: { action: 'recover-residue' } }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function probe(entry, { targetIdentity = entry.identity } = {}) {
|
|
246
|
+
try {
|
|
247
|
+
const prepared = await prepare(entry)
|
|
248
|
+
const result = await mount.probe({
|
|
249
|
+
identity: undefined,
|
|
250
|
+
generation: 0,
|
|
251
|
+
client: prepared.client,
|
|
252
|
+
secretBearing: prepared.secretBearing,
|
|
253
|
+
secrets: prepared.secrets,
|
|
254
|
+
fiberOf,
|
|
255
|
+
beforeCreate: ({ groupId, identity, generation }) => registerBridge(groupId, identity, generation),
|
|
256
|
+
afterCleanup: ({ groupId }) => releaseBridge(groupId),
|
|
257
|
+
discovery: ({ identity, generation }) => discoveryFor(identity, generation),
|
|
258
|
+
})
|
|
259
|
+
const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
|
|
260
|
+
// Receipts share one shape with lastMount: the row reads `diagnostic`.
|
|
261
|
+
receipts.test.set(targetIdentity, { time: now(), status: outcome.status, discovery: outcome.discovery, ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) })
|
|
262
|
+
return outcome
|
|
148
263
|
} catch (cause) {
|
|
149
|
-
|
|
150
|
-
|
|
264
|
+
const status = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : 'probe-failed'
|
|
265
|
+
const outcome = { status, identity: targetIdentity, error: mcpDiagnostic(cause, { code: cause?.code ?? status }), cleanup: cause?.cleanup }
|
|
266
|
+
receipts.test.set(targetIdentity, { time: now(), status, diagnostic: outcome.error })
|
|
267
|
+
return outcome
|
|
151
268
|
}
|
|
152
|
-
return { groupId, release }
|
|
153
269
|
}
|
|
154
270
|
|
|
155
|
-
|
|
271
|
+
let started = false
|
|
156
272
|
async function start() {
|
|
273
|
+
if (started) return { mounted: mount.mounted.size, alreadyStarted: true }
|
|
274
|
+
started = true
|
|
157
275
|
const swept = await mount.sweepResidue(listEntryIds())
|
|
158
276
|
const current = await registry.read()
|
|
159
277
|
if (current.kind !== 'ok') {
|
|
@@ -167,83 +285,191 @@ export function createMcpManager(dependencies = {}) {
|
|
|
167
285
|
}
|
|
168
286
|
let mountedCount = 0
|
|
169
287
|
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
|
-
}
|
|
288
|
+
const result = await serialize(entry.identity, () => reconcileOne(entry.identity, entry))
|
|
289
|
+
if (result.status === 'mounted') mountedCount += 1
|
|
290
|
+
else if (result.status !== 'saved-degraded') startErrors.push({ identity: entry.identity, reason: result.status })
|
|
177
291
|
}
|
|
178
292
|
return { mounted: mountedCount }
|
|
293
|
+
|
|
294
|
+
}
|
|
295
|
+
const healthy = identity => runtime.snapshot(identity).phase === 'mounted' && mount.mounted.has(identity)
|
|
296
|
+
&& (mount.mounted.get(identity)?.state ?? 'mounted') === 'mounted'
|
|
297
|
+
|
|
298
|
+
/** Recover one identity inside its queue slot: sweep untracked residue, then strictly remount. */
|
|
299
|
+
function recoverOne(identity, assertOwner) {
|
|
300
|
+
return serialize(identity, async () => {
|
|
301
|
+
// Re-read inside the slot: an earlier queued operation may have
|
|
302
|
+
// remounted, edited or removed this identity meanwhile.
|
|
303
|
+
const current = await registry.read()
|
|
304
|
+
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
|
+
const token = beginOperation(identity)
|
|
320
|
+
// A tracked failed mount record is retried by reconcileOne's unmount; a
|
|
321
|
+
// Loader row the mount map no longer tracks is swept first, or the
|
|
322
|
+
// same-name replacement could never be created.
|
|
323
|
+
if (!mount.mounted.has(identity) && residuePresent) {
|
|
324
|
+
const swept = await mount.sweepResidue([groupId])
|
|
325
|
+
if (!swept.includes(groupId)) {
|
|
326
|
+
runtime.finish(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
|
|
327
|
+
const diagnostic = mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
|
|
328
|
+
receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
|
|
329
|
+
return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
|
|
330
|
+
}
|
|
331
|
+
releaseBridge(groupId)
|
|
332
|
+
}
|
|
333
|
+
if (assertOwner) {
|
|
334
|
+
try { await assertOwner('mount') } catch (cause) {
|
|
335
|
+
// Cleanup may already have removed the old mount. Keep admission
|
|
336
|
+
// closed without stranding the queue fence in `replacing`.
|
|
337
|
+
runtime.finish(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
|
|
338
|
+
throw cause
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return reconcileOne(identity, entry, token)
|
|
342
|
+
})
|
|
179
343
|
}
|
|
180
344
|
|
|
181
345
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
346
|
+
* Residue recovery (user-triggered via the capabilities command verb
|
|
347
|
+
* mcp-recover, the mcp mutation action 'recover' and the panel's Recover
|
|
348
|
+
* row action). Healthy mounted identities are never touched; every other
|
|
349
|
+
* registered identity is strictly remounted and every orphan residue row is
|
|
350
|
+
* removed. `identity` scopes the work to one server. The optional owner
|
|
351
|
+
* callback runs inside every queue slot before cleanup and before remount.
|
|
186
352
|
*/
|
|
187
|
-
async function
|
|
353
|
+
async function recoverResidue({ identity = null, assertOwner = null } = {}) {
|
|
188
354
|
const current = await registry.read()
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
355
|
+
if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', skipped: current.kind, results: [] }
|
|
356
|
+
const servers = current.kind === 'ok' ? current.servers : {}
|
|
357
|
+
const orphans = listEntryIds().filter(isMcpResidueId).map(id => id.slice(MCP_GROUP_PREFIX.length)).filter(id => !servers[id])
|
|
358
|
+
let candidates = [...new Set([...Object.keys(servers).filter(id => !healthy(id)), ...orphans])]
|
|
359
|
+
if (identity !== null) candidates = candidates.filter(id => id === identity)
|
|
360
|
+
const results = []
|
|
361
|
+
for (const id of candidates) {
|
|
362
|
+
try { results.push(await recoverOne(id, assertOwner)) } catch (cause) {
|
|
363
|
+
if (!assertOwner || cause?.code !== 'owner-changed') throw cause
|
|
364
|
+
const recovered = results.filter(result => result.status === 'mounted' || result.status === 'removed').length
|
|
365
|
+
return {
|
|
366
|
+
status: 'owner-changed', recovered, results,
|
|
367
|
+
error: mcpDiagnostic(cause, { code: 'owner-changed', action: 'inspect-actual-state' }),
|
|
368
|
+
next: { action: 'inspect-actual-state' },
|
|
369
|
+
}
|
|
197
370
|
}
|
|
198
371
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
372
|
+
const recovered = results.filter(result => result.status === 'mounted' || result.status === 'removed').length
|
|
373
|
+
return { status: recovered === results.length ? 'recovered' : 'recovery-incomplete', recovered, results }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function adopt(entry) {
|
|
377
|
+
const holder = hostRowHoldingReservation(listRows(), entry.client?.serverName ?? entry.transport?.serverName ?? entry.identity)
|
|
378
|
+
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
|
+
return serialize(entry.identity, async () => {
|
|
380
|
+
const current = await registry.read()
|
|
381
|
+
const revision = current.kind === 'ok' ? current.revision : 0
|
|
382
|
+
const token = beginOperation(entry.identity)
|
|
383
|
+
const registered = await registry.register(entry, revision)
|
|
384
|
+
if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
|
|
385
|
+
const latest = await registry.read()
|
|
386
|
+
return reconcileOne(entry.identity, latest.servers[entry.identity], token)
|
|
387
|
+
})
|
|
205
388
|
}
|
|
206
389
|
|
|
207
|
-
/** Creation-time schema hiding (8.4③): per-agent deny of public names whose server is not accepted. */
|
|
208
390
|
function agentCreated(payload) {
|
|
209
391
|
const agent = payload?.agent
|
|
210
392
|
if (publicNames.size === 0) return
|
|
211
|
-
const deny = [...publicNames.entries()].filter(([,
|
|
393
|
+
const deny = [...publicNames.entries()].filter(([, owner]) => !gate.enabledFor(agent, typeof owner === 'string' ? owner : owner.identity)).map(([name]) => name)
|
|
212
394
|
if (deny.length > 0) {
|
|
213
395
|
try { agent?.ctx?.tools?.restrict?.({ deny }) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
|
|
214
396
|
}
|
|
215
397
|
}
|
|
216
398
|
ctx.on?.('agent/created', payload => { agentCreated(payload) })
|
|
217
399
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
400
|
+
async function list() {
|
|
401
|
+
const current = await registry.read()
|
|
402
|
+
const rows = listRows()
|
|
403
|
+
const partitioned = partitionMcpRows(rows, mount.mounted, current.kind === 'ok' ? current.servers : {})
|
|
404
|
+
if (current.kind === 'absent') return { managed: [], unmanaged: partitioned.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
|
|
405
|
+
if (current.kind !== 'ok') return { managed: [], unmanaged: partitioned.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
|
|
406
|
+
const defaults = new Set(current.enabled)
|
|
407
|
+
const managed = Object.values(current.servers).map(entry => {
|
|
408
|
+
const phase = runtime.snapshot(entry.identity)
|
|
409
|
+
const mountedRecord = mount.mounted.get(entry.identity)
|
|
410
|
+
const mounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
|
|
411
|
+
const holder = hostRowHoldingReservation(rows, entry.client?.serverName ?? entry.identity)
|
|
412
|
+
const state = holder ? 'conflict' : mounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
|
|
413
|
+
const discovery = discoveryFor(entry.identity, entry.generation)
|
|
414
|
+
return {
|
|
415
|
+
identity: entry.identity,
|
|
416
|
+
label: entry.label,
|
|
417
|
+
serverName: entry.client?.serverName ?? entry.identity,
|
|
418
|
+
transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
|
|
419
|
+
incarnation: entry.incarnation ?? null,
|
|
420
|
+
generation: entry.generation,
|
|
421
|
+
defaultEnabled: defaults.has(entry.identity),
|
|
422
|
+
state,
|
|
423
|
+
statusSource: mounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
|
|
424
|
+
runtimePhase: phase.phase,
|
|
425
|
+
discovery: mounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
|
|
426
|
+
lastMount: receipts.mount.get(entry.identity) ?? null,
|
|
427
|
+
lastTest: receipts.test.get(entry.identity) ?? null,
|
|
428
|
+
startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
|
|
429
|
+
liveReconnect: 'unknown',
|
|
430
|
+
processExit: 'unknown',
|
|
431
|
+
stderr: 'unknown',
|
|
432
|
+
readOnly: false,
|
|
433
|
+
config: redactMcpEntry(entry),
|
|
434
|
+
fingerprint: fingerprintMcpEntry(entry),
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
return { managed, unmanaged: partitioned.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const gateFace = {
|
|
441
|
+
admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
|
|
442
|
+
permit: (agent, identity, generation) => gate.permit(agent, identity, generation),
|
|
443
|
+
assertPermit: gate.assertPermit,
|
|
444
|
+
enabledFor: (agent, identity) => gate.enabledFor(agent, identity),
|
|
225
445
|
drain,
|
|
226
|
-
|
|
227
|
-
|
|
446
|
+
}
|
|
447
|
+
try { ctx.reflect?.provide?.('weirMcpGate', gateFace) } catch { /* older row */ }
|
|
448
|
+
const face = {
|
|
449
|
+
start, startErrors, adopt, gate: gateFace, drain, registry, runtime,
|
|
450
|
+
recoverResidue,
|
|
451
|
+
list,
|
|
228
452
|
publicNames,
|
|
229
|
-
|
|
453
|
+
mounted: mount.mounted,
|
|
454
|
+
beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
|
|
455
|
+
remove: identity => serialize(identity, () => removeRuntime(identity)),
|
|
456
|
+
/** Run one same-identity operation after every earlier one settled. */
|
|
457
|
+
serialize,
|
|
458
|
+
support,
|
|
459
|
+
serverNameConflict: (serverName, identity) => {
|
|
460
|
+
const holder = hostRowHoldingReservation(listRows(), serverName)
|
|
461
|
+
return holder ? `host row "${holder.id ?? identity}" already reserves serverName "${serverName}"` : null
|
|
462
|
+
},
|
|
463
|
+
prepare, describeExecution,
|
|
464
|
+
mcpGroupOptions,
|
|
230
465
|
}
|
|
231
466
|
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.
|
|
467
|
+
void Promise.resolve().then(() => face.start()).catch(cause => {
|
|
468
|
+
startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
|
|
469
|
+
onError('mcp-manager-start-failed', cause)
|
|
470
|
+
})
|
|
245
471
|
return () => {
|
|
246
|
-
try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
|
|
472
|
+
try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
|
|
247
473
|
}
|
|
248
474
|
}
|
|
249
475
|
}
|
|
@@ -251,4 +477,4 @@ export function createMcpManager(dependencies = {}) {
|
|
|
251
477
|
export const name = 'weir-mcp-manager'
|
|
252
478
|
export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources']
|
|
253
479
|
export const apply = createMcpManager()
|
|
254
|
-
export const __message = message
|
|
480
|
+
export const __message = message
|