weir-harness 2.0.0 → 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/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 +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/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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Shared Host MCP operations. UI mutations, user commands and AI installation
|
|
2
|
+
// converge here: normalized preview → ownership/support checks → staged refs →
|
|
3
|
+
// optional probe → current-read fingerprint/CAS → strict targeted mount/readback.
|
|
4
|
+
import { parseMcpDocument, normalizeMcpServer, fingerprintMcpEntry, hasMcpSecrets, assertMcpPlanSize, resolveMcpEditArgs } from './mcp-config.js'
|
|
5
|
+
import { stageMcpCredentials, compensateMcpCredentialAttempt } from './mcp-credentials.js'
|
|
6
|
+
import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
|
|
7
|
+
import { mcpIdentityLockFor } from './mcp-runtime.js'
|
|
8
|
+
import { AUDIT_TYPES } from '../shared/audit.js'
|
|
9
|
+
|
|
10
|
+
const has = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
const clone = value => value == null ? value : structuredClone(value)
|
|
12
|
+
const currentServers = current => current?.kind === 'ok' ? current.servers : {}
|
|
13
|
+
const committed = result => result?.status === 'committed'
|
|
14
|
+
const failedStatuses = new Set(['revision-conflict', 'conflict', 'validation-failed', 'unsupported', 'probe-failed', 'owner-changed', 'indeterminate', 'cleanup-failed', 'cleanup-unknown', 'cancelled'])
|
|
15
|
+
|
|
16
|
+
function failure(status, code, cause, extra = {}) {
|
|
17
|
+
return { status, saved: false, error: mcpDiagnostic(cause ?? new Error(code), { code, action: status === 'cleanup-unknown' ? 'recover-residue' : 'retry' }), ...extra }
|
|
18
|
+
}
|
|
19
|
+
function resultForBatch(item) {
|
|
20
|
+
const succeeded = item.status === 'mounted'
|
|
21
|
+
return { ...item, succeeded, failed: !succeeded, notAttempted: item.status === 'not-attempted' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mergeRefs(existing, incoming) {
|
|
25
|
+
if (!incoming) return clone(existing)
|
|
26
|
+
const out = clone(existing ?? {})
|
|
27
|
+
for (const kind of ['env', 'headers', 'args']) if (incoming[kind]) out[kind] = { ...(out[kind] ?? {}), ...clone(incoming[kind]) }
|
|
28
|
+
if (incoming.url) out.url = clone(incoming.url)
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
function editSource(existing, raw) {
|
|
32
|
+
const source = clone(raw)
|
|
33
|
+
source.identity = existing.identity
|
|
34
|
+
source.label ??= existing.label
|
|
35
|
+
source.serverName ??= existing.client?.serverName
|
|
36
|
+
if (existing.transport?.kind === 'stdio' || existing.client?.transport === 'stdio') {
|
|
37
|
+
source.command ??= existing.client?.command
|
|
38
|
+
// Omitted args keep the stored argv; supplied args resolve projection
|
|
39
|
+
// placeholders back to the stored values and refuse any other placeholder.
|
|
40
|
+
source.args = source.args == null ? clone(existing.client?.args ?? []) : resolveMcpEditArgs(existing, source.args)
|
|
41
|
+
} else {
|
|
42
|
+
source.url ??= clone(existing.secretRefs?.url ?? existing.client?.url)
|
|
43
|
+
source.headers ??= undefined
|
|
44
|
+
}
|
|
45
|
+
source.secretRefs = mergeRefs(existing.secretRefs, source.secretRefs)
|
|
46
|
+
return source
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createMcpOperations({ registry, manager, credentials, audit, assertOwner = async () => {}, session = null }) {
|
|
50
|
+
if (!registry || !manager) throw new TypeError('MCP operations require registry and manager')
|
|
51
|
+
const serialize = mcpIdentityLockFor(manager)
|
|
52
|
+
const emit = (action, result, identity) => {
|
|
53
|
+
try {
|
|
54
|
+
audit?.(session, AUDIT_TYPES.capabilityMcp, {
|
|
55
|
+
action, identity: identity ?? null, status: result.status,
|
|
56
|
+
generation: result.generation ?? null, incarnation: result.incarnation ?? null,
|
|
57
|
+
registryRevision: result.registryRevision ?? null, saved: result.saved === true,
|
|
58
|
+
errorCode: result.error?.code ?? null,
|
|
59
|
+
})
|
|
60
|
+
} catch { /* audit never changes policy */ }
|
|
61
|
+
}
|
|
62
|
+
const finalize = (action, result, identity, secrets = []) => {
|
|
63
|
+
const bounded = sanitizeMcpPayload(result, secrets)
|
|
64
|
+
assertMcpPlanSize(bounded)
|
|
65
|
+
emit(action, bounded, identity)
|
|
66
|
+
return bounded
|
|
67
|
+
}
|
|
68
|
+
const owner = async phase => {
|
|
69
|
+
try { await assertOwner(phase) } catch (cause) {
|
|
70
|
+
const error = new Error(`MCP mutation ownership changed before ${phase}`, { cause })
|
|
71
|
+
error.code = 'owner-changed'
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const read = async () => {
|
|
76
|
+
const current = await registry.read()
|
|
77
|
+
if (!['ok', 'absent'].includes(current.kind)) {
|
|
78
|
+
const error = new Error(`MCP registry is ${current.kind}`)
|
|
79
|
+
error.code = 'registry-unavailable'
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
return current
|
|
83
|
+
}
|
|
84
|
+
const revisionConflict = (current, extra = {}) => ({ status: 'revision-conflict', saved: false, registryRevision: current.revision ?? 0, next: { action: 'preview-again' }, ...extra })
|
|
85
|
+
|
|
86
|
+
async function preview(request = {}) {
|
|
87
|
+
const surface = request.surface ?? (request.refsOnly ? 'refs' : 'ui')
|
|
88
|
+
const parsed = parseMcpDocument(request.document ?? request.config ?? request, { surface })
|
|
89
|
+
const current = await read()
|
|
90
|
+
const mode = request.mode ?? (request.action === 'edit' ? 'edit' : 'add')
|
|
91
|
+
const targets = parsed.entries.map(item => {
|
|
92
|
+
const existing = currentServers(current)[item.entry.identity]
|
|
93
|
+
return {
|
|
94
|
+
identity: item.entry.identity,
|
|
95
|
+
fingerprint: existing ? fingerprintMcpEntry(existing) : null,
|
|
96
|
+
generation: existing?.generation ?? null,
|
|
97
|
+
incarnation: existing?.incarnation ?? null,
|
|
98
|
+
conflict: mode === 'add' && existing ? 'identity-exists' : mode === 'edit' && !existing ? 'identity-unregistered' : null,
|
|
99
|
+
namespaceConflict: namespaceConflict(current, item.entry),
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
return {
|
|
103
|
+
status: 'preview', plan: parsed.plan, digest: parsed.digest, targets,
|
|
104
|
+
registryRevision: current.revision ?? 0,
|
|
105
|
+
capabilities: manager.support(),
|
|
106
|
+
saved: false,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function namespaceConflict(current, entry) {
|
|
111
|
+
const serverName = entry.client?.serverName ?? entry.identity
|
|
112
|
+
const sibling = Object.values(currentServers(current)).find(other => other.identity !== entry.identity && (other.client?.serverName ?? other.identity) === serverName)
|
|
113
|
+
if (sibling) return `managed server "${sibling.identity}" already reserves serverName "${serverName}"`
|
|
114
|
+
return manager.serverNameConflict?.(serverName, entry.identity) ?? null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureSupport(normalized) {
|
|
118
|
+
const support = manager.support()
|
|
119
|
+
if (normalized.hasSecrets && !support.ephemeral) return failure('unsupported', 'ephemeral-loader-unavailable', new Error('secret-bearing MCP work requires a proven ephemeral Loader parent'))
|
|
120
|
+
if (Object.keys(normalized.secretValues ?? {}).length > 0 && !support.credentials) return failure('unsupported', 'credentials-unavailable', new Error('new MCP secret values require DSH credentials'))
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function compensate(attempt, provenUncommitted) {
|
|
125
|
+
return compensateMcpCredentialAttempt({ attempt, registry, credentials, provenUncommitted })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Every operation that fences a registered identity runs inside the
|
|
130
|
+
* manager's per-identity queue, so overlapping same-identity operations are
|
|
131
|
+
* strictly serialized. A Test of a fresh (add-mode) config is probe-only: it
|
|
132
|
+
* uses a throwaway probe identity, never fences or closes admission of the
|
|
133
|
+
* named identity, and therefore needs no queue slot.
|
|
134
|
+
*/
|
|
135
|
+
async function mutateOne(action, request, normalized, options = {}) {
|
|
136
|
+
const identity = normalized.entry.identity
|
|
137
|
+
if (action === 'test' && options.liveTarget !== true) return runOne(action, request, normalized, options, false)
|
|
138
|
+
return serialize(identity, () => runOne(action, request, normalized, options, true))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runOne(action, request, normalized, options, fenced) {
|
|
142
|
+
const identity = normalized.entry.identity
|
|
143
|
+
const token = fenced ? manager.beginOperation(identity, action === 'remove' ? 'removing' : 'replacing') : null
|
|
144
|
+
let attempt = null
|
|
145
|
+
let committedWrite = false
|
|
146
|
+
let actualRevision = null
|
|
147
|
+
const secrets = []
|
|
148
|
+
const finish = async (result, { beforeCommit = false, provenUncommitted = false } = {}) => {
|
|
149
|
+
const projectedSecrets = () => [...secrets, ...(attempt?.secrets ?? [])]
|
|
150
|
+
if (beforeCommit && token) manager.abortOperation(token)
|
|
151
|
+
if (attempt && !committedWrite) {
|
|
152
|
+
const cleanup = await compensate(attempt, provenUncommitted)
|
|
153
|
+
if (cleanup.status !== 'cleaned') return finalize(action, { ...result, status: cleanup.status, cleanup, saved: committedWrite || result.saved === true }, identity, projectedSecrets())
|
|
154
|
+
}
|
|
155
|
+
return finalize(action, { ...result, registryRevision: result.registryRevision ?? actualRevision }, identity, projectedSecrets())
|
|
156
|
+
}
|
|
157
|
+
const indeterminateAfterWrite = async cause => {
|
|
158
|
+
let observed = null
|
|
159
|
+
try { observed = await read() } catch { /* the outcome stays unknown */ }
|
|
160
|
+
const registered = observed?.kind === 'ok' ? observed.servers[identity] : null
|
|
161
|
+
return {
|
|
162
|
+
status: 'indeterminate',
|
|
163
|
+
saved: action === 'remove' ? observed?.kind === 'ok' && !registered : Boolean(registered),
|
|
164
|
+
removed: action === 'remove' && observed?.kind === 'ok' && !registered,
|
|
165
|
+
generation: registered?.generation,
|
|
166
|
+
incarnation: registered?.incarnation ?? null,
|
|
167
|
+
registryRevision: observed?.revision ?? actualRevision,
|
|
168
|
+
error: mcpDiagnostic(cause, { code: 'indeterminate' }),
|
|
169
|
+
next: { action: 'reread-registry' },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await owner('preflight')
|
|
174
|
+
let current = await read()
|
|
175
|
+
actualRevision = current.revision ?? 0
|
|
176
|
+
const existing = currentServers(current)[identity]
|
|
177
|
+
if (options.resolveExisting) normalized = await options.resolveExisting(existing, current)
|
|
178
|
+
const expectedFingerprint = options.targetFingerprint ?? normalized.targetFingerprint ?? (existing ? fingerprintMcpEntry(existing) : null)
|
|
179
|
+
// An absent registry has no raw revision while preview reports 0; compare the
|
|
180
|
+
// same normalized value or the first UI Add/Test carrying expectedRevision 0 falsely conflicts.
|
|
181
|
+
if (!options.batch && Number.isSafeInteger(request.expectedRevision) && (current.revision ?? 0) !== request.expectedRevision) return finish(revisionConflict(current), { beforeCommit: true, provenUncommitted: true })
|
|
182
|
+
if (action === 'add' || action === 'installFromAgent') {
|
|
183
|
+
if (existing) return finish({ status: 'conflict', saved: false, error: { code: 'name-conflict', message: `MCP identity "${identity}" already exists` } }, { beforeCommit: true, provenUncommitted: true })
|
|
184
|
+
} else if (action === 'edit' || action === 'restart' || action === 'remove') {
|
|
185
|
+
if (!existing) return finish({ status: 'conflict', saved: false, error: { code: 'identity-unregistered', message: `MCP identity "${identity}" is not registered` } }, { beforeCommit: true, provenUncommitted: true })
|
|
186
|
+
if (expectedFingerprint && fingerprintMcpEntry(existing) !== expectedFingerprint) return finish(revisionConflict(current), { beforeCommit: true, provenUncommitted: true })
|
|
187
|
+
if (action === 'edit' && (existing.client?.serverName ?? identity) !== (normalized.entry.client?.serverName ?? identity) && !request.confirmNamespaceChange) {
|
|
188
|
+
return finish({ status: 'confirmation-required', saved: false, error: { code: 'namespace-change-requires-confirmation', message: 'Changing the public serverName requires distinct confirmation.' } }, { beforeCommit: true, provenUncommitted: true })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const conflict = action === 'remove' ? null : namespaceConflict(current, normalized.entry)
|
|
192
|
+
if (conflict) return finish({ status: 'conflict', saved: false, error: { code: 'name-conflict', message: conflict } }, { beforeCommit: true, provenUncommitted: true })
|
|
193
|
+
const unsupported = ensureSupport(normalized)
|
|
194
|
+
if (unsupported) return finish(unsupported, { beforeCommit: true, provenUncommitted: true })
|
|
195
|
+
|
|
196
|
+
if (action !== 'remove' && action !== 'restart') {
|
|
197
|
+
await owner('credentials')
|
|
198
|
+
const staged = await stageMcpCredentials({ entry: normalized.entry, secretValues: normalized.secretValues, credentials, registry, generation: (existing?.generation ?? 0) + 1 })
|
|
199
|
+
normalized.entry = staged.entry
|
|
200
|
+
attempt = staged.attempt
|
|
201
|
+
}
|
|
202
|
+
if (options.requireProbe || action === 'test') {
|
|
203
|
+
await owner('probe')
|
|
204
|
+
const probe = await manager.probe(normalized.entry, { targetIdentity: identity })
|
|
205
|
+
if (probe.status !== 'mounted') {
|
|
206
|
+
return finish({ ...probe, saved: false, next: { action: 'edit-and-retry' } }, { beforeCommit: true, provenUncommitted: true })
|
|
207
|
+
}
|
|
208
|
+
if (action === 'test') return finish({ status: 'mounted', identity, saved: false, probe: true, discovery: probe.discovery, cleanup: probe.cleanup, next: { action: 'add-or-edit' } }, { beforeCommit: true, provenUncommitted: true })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (action === 'remove') {
|
|
212
|
+
current = await read()
|
|
213
|
+
const latest = currentServers(current)[identity]
|
|
214
|
+
if (!latest || fingerprintMcpEntry(latest) !== expectedFingerprint) return finish(revisionConflict(current), { beforeCommit: true, provenUncommitted: true })
|
|
215
|
+
await owner('CAS')
|
|
216
|
+
let written
|
|
217
|
+
try { written = await registry.remove(identity, current.revision ?? 0) } catch (cause) {
|
|
218
|
+
return finish(await indeterminateAfterWrite(cause), { provenUncommitted: false })
|
|
219
|
+
}
|
|
220
|
+
if (!committed(written)) return finish(revisionConflict(current), { beforeCommit: true, provenUncommitted: true })
|
|
221
|
+
committedWrite = true
|
|
222
|
+
actualRevision = written.revision
|
|
223
|
+
await owner('mount')
|
|
224
|
+
const removed = await manager.removeRuntime(identity, token)
|
|
225
|
+
return finish({ ...removed, identity, saved: true, removed: removed.status === 'removed', defaultRemoved: Boolean(written.defaultRemoved), registryRevision: actualRevision }, { provenUncommitted: false })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
current = await read()
|
|
229
|
+
const latest = currentServers(current)[identity]
|
|
230
|
+
if ((action === 'add' || action === 'installFromAgent') && latest) return finish({ status: 'conflict', saved: false, error: { code: 'name-conflict', message: `MCP identity "${identity}" was created during the operation` } }, { beforeCommit: true, provenUncommitted: true })
|
|
231
|
+
if (['edit', 'restart'].includes(action) && (!latest || fingerprintMcpEntry(latest) !== expectedFingerprint)) return finish(revisionConflict(current), { beforeCommit: true, provenUncommitted: true })
|
|
232
|
+
const latestConflict = action === 'restart' ? null : namespaceConflict(current, normalized.entry)
|
|
233
|
+
if (latestConflict) return finish({ status: 'conflict', saved: false, error: { code: 'name-conflict', message: latestConflict } }, { beforeCommit: true, provenUncommitted: true })
|
|
234
|
+
await owner('CAS')
|
|
235
|
+
let written
|
|
236
|
+
try {
|
|
237
|
+
written = await registry.register(normalized.entry, current.revision ?? 0)
|
|
238
|
+
} catch (cause) {
|
|
239
|
+
return finish(await indeterminateAfterWrite(cause), { provenUncommitted: false })
|
|
240
|
+
}
|
|
241
|
+
if (!committed(written)) return finish(revisionConflict(current), { beforeCommit: true, provenUncommitted: true })
|
|
242
|
+
committedWrite = true
|
|
243
|
+
actualRevision = written.revision
|
|
244
|
+
const afterCommit = await read()
|
|
245
|
+
const registered = afterCommit.servers[identity]
|
|
246
|
+
await owner('mount')
|
|
247
|
+
const mounted = await manager.reconcileOne(identity, registered, token)
|
|
248
|
+
const result = {
|
|
249
|
+
...mounted,
|
|
250
|
+
identity,
|
|
251
|
+
generation: registered.generation,
|
|
252
|
+
incarnation: registered.incarnation ?? null,
|
|
253
|
+
registryRevision: actualRevision,
|
|
254
|
+
saved: true,
|
|
255
|
+
succeeded: mounted.status === 'mounted',
|
|
256
|
+
...(normalized.legacyLiteralSecrets ? { warnings: ['legacy-literal-secrets-in-command-history'] } : {}),
|
|
257
|
+
...(action === 'installFromAgent' ? { next: { action: 'select-and-apply' } } : {}),
|
|
258
|
+
}
|
|
259
|
+
return finish(result, { provenUncommitted: false })
|
|
260
|
+
} catch (cause) {
|
|
261
|
+
const status = cause?.code === 'owner-changed' ? 'owner-changed' : cause?.status === 'unsupported' || cause?.code === 'unsupported' ? 'unsupported' : cause?.code === 'registry-unavailable' ? 'indeterminate' : cause?.code === 'credential-missing' || cause?.code === 'invalid-config' || cause?.code === 'credential-write-failed' ? 'validation-failed' : failedStatuses.has(cause?.status) ? cause.status : 'indeterminate'
|
|
262
|
+
const result = failure(status, cause?.code ?? status, cause, { saved: committedWrite, registryRevision: actualRevision, next: { action: status === 'owner-changed' ? 'inspect-actual-state' : 'retry' } })
|
|
263
|
+
return finish(result, { beforeCommit: !committedWrite, provenUncommitted: !committedWrite })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function execute(request = {}) {
|
|
268
|
+
const action = request.action
|
|
269
|
+
if (action === 'preview') return finalize('preview', await preview(request), null)
|
|
270
|
+
if (action === 'setDefault') {
|
|
271
|
+
try {
|
|
272
|
+
const current = await read()
|
|
273
|
+
if (Number.isSafeInteger(request.expectedRevision) && (current.revision ?? 0) !== request.expectedRevision) return finalize(action, revisionConflict(current), request.identity)
|
|
274
|
+
await owner('CAS')
|
|
275
|
+
const result = await registry.setDefault(request.identity, request.enabled === true, current.revision ?? 0)
|
|
276
|
+
if (!committed(result)) return finalize(action, revisionConflict(current), request.identity)
|
|
277
|
+
return finalize(action, { status: 'saved', identity: request.identity, saved: true, defaultEnabled: request.enabled === true, registryRevision: result.revision }, request.identity)
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
return finalize(action, failure(cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed', cause?.code ?? 'default-failed', cause), request.identity)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (action === 'recover') {
|
|
283
|
+
const identity = typeof request.identity === 'string' ? request.identity : null
|
|
284
|
+
try {
|
|
285
|
+
if (typeof manager.recoverResidue !== 'function') return finalize(action, failure('unsupported', 'recover-unavailable', new Error('this MCP manager cannot recover residue')), identity)
|
|
286
|
+
await owner('mount')
|
|
287
|
+
const result = await manager.recoverResidue({ identity, assertOwner: owner })
|
|
288
|
+
const next = result.status === 'recovered' ? 'none' : result.status === 'owner-changed' ? 'inspect-actual-state' : 'inspect-diagnostics'
|
|
289
|
+
return finalize(action, { ...result, saved: false, next: { action: next } }, identity)
|
|
290
|
+
} catch (cause) {
|
|
291
|
+
const status = cause?.code === 'owner-changed' ? 'owner-changed' : 'indeterminate'
|
|
292
|
+
return finalize(action, failure(status, cause?.code ?? status, cause, { next: { action: 'inspect-actual-state' } }), identity)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const unresolved = () => ({ entry: { identity: request.identity, client: { serverName: request.identity } }, secretValues: {}, hasSecrets: false, legacyLiteralSecrets: false })
|
|
296
|
+
if (action === 'remove' || action === 'restart') {
|
|
297
|
+
return mutateOne(action, request, unresolved(), {
|
|
298
|
+
targetFingerprint: request.targetFingerprint,
|
|
299
|
+
resolveExisting: existing => ({ entry: clone(existing), secretValues: {}, hasSecrets: hasMcpSecrets(existing), legacyLiteralSecrets: false }),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
if (!['add', 'edit', 'test', 'installFromAgent'].includes(action)) return finalize(action ?? 'unknown', failure('validation-failed', 'unknown-operation', new Error(`unsupported MCP operation ${action}`)), request.identity)
|
|
303
|
+
const surface = request.surface ?? (request.refsOnly || action === 'installFromAgent' ? 'refs' : 'ui')
|
|
304
|
+
if (action === 'edit' || (action === 'test' && request.mode === 'edit')) {
|
|
305
|
+
return mutateOne(action, request, unresolved(), {
|
|
306
|
+
targetFingerprint: request.targetFingerprint,
|
|
307
|
+
liveTarget: true,
|
|
308
|
+
resolveExisting: existing => {
|
|
309
|
+
if (!existing) return unresolved()
|
|
310
|
+
const normalized = normalizeMcpServer(editSource(existing, request.config ?? request.document ?? {}), { surface, identity: request.identity })
|
|
311
|
+
normalized.entry.client.env = clone(existing.client?.env ?? {})
|
|
312
|
+
if (existing.client?.headers) normalized.entry.client.headers = clone(existing.client.headers)
|
|
313
|
+
normalized.entry.owner = clone(existing.owner)
|
|
314
|
+
normalized.entry.configuredAt = existing.configuredAt
|
|
315
|
+
normalized.entry.incarnation = existing.incarnation ?? null
|
|
316
|
+
return normalized
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
const normalized = normalizeMcpServer(request.config ?? request.document ?? {}, { surface, identity: request.identity })
|
|
321
|
+
if (action === 'test') return mutateOne(action, request, normalized, { targetFingerprint: request.targetFingerprint })
|
|
322
|
+
if (action === 'installFromAgent') return mutateOne(action, request, normalized, { requireProbe: true })
|
|
323
|
+
return mutateOne(action, request, normalized, { targetFingerprint: request.targetFingerprint })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function executeBatch(request = {}) {
|
|
327
|
+
const parsed = parseMcpDocument(request.document ?? request.config ?? request, { surface: request.surface ?? (request.refsOnly ? 'refs' : 'ui') })
|
|
328
|
+
const planned = await preview({ ...request, action: 'preview' })
|
|
329
|
+
const items = []
|
|
330
|
+
let aborted = null
|
|
331
|
+
for (let index = 0; index < parsed.entries.length; index += 1) {
|
|
332
|
+
if (aborted) {
|
|
333
|
+
items.push(resultForBatch({ status: 'not-attempted', identity: parsed.entries[index].entry.identity, saved: false, error: { code: 'batch-aborted', message: aborted } }))
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const normalized = parsed.entries[index]
|
|
337
|
+
const action = request.mode === 'edit' ? 'edit' : 'add'
|
|
338
|
+
const result = await mutateOne(action, { ...request, identity: normalized.entry.identity, expectedRevision: undefined }, normalized, { batch: true, requireProbe: true, targetFingerprint: planned.targets[index]?.fingerprint })
|
|
339
|
+
items.push(resultForBatch(result))
|
|
340
|
+
if (['owner-changed', 'indeterminate'].includes(result.status) || result.error?.code === 'registry-unavailable') aborted = `batch aborted after ${normalized.entry.identity}: ${result.status}`
|
|
341
|
+
}
|
|
342
|
+
const succeeded = items.every(item => item.succeeded)
|
|
343
|
+
return finalize('batch', { status: succeeded ? 'mounted' : 'partial', batch: true, atomic: false, succeeded, items }, null)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
preview,
|
|
348
|
+
execute,
|
|
349
|
+
executeBatch,
|
|
350
|
+
add: request => execute({ ...request, action: 'add' }),
|
|
351
|
+
edit: request => execute({ ...request, action: 'edit' }),
|
|
352
|
+
remove: request => execute({ ...request, action: 'remove' }),
|
|
353
|
+
restart: request => execute({ ...request, action: 'restart' }),
|
|
354
|
+
test: request => execute({ ...request, action: 'test' }),
|
|
355
|
+
setDefault: request => execute({ ...request, action: 'setDefault' }),
|
|
356
|
+
recover: request => execute({ ...request, action: 'recover' }),
|
|
357
|
+
installFromAgent: request => execute({ ...request, action: 'installFromAgent', refsOnly: true, surface: 'refs' }),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// MCP-specific use of the shared GUI executable/PATH adapter. Secret argv/env
|
|
2
|
+
// values are used only in memory; execution metadata is safe for confirmation.
|
|
3
|
+
import { resolveExecutable, spawnArgv, childEnvironment } from '../process/index.js'
|
|
4
|
+
import { McpLifecycleError } from './mcp-diagnostics.js'
|
|
5
|
+
|
|
6
|
+
export function createMcpProcessAdapter({
|
|
7
|
+
subprocess,
|
|
8
|
+
platform = process.platform,
|
|
9
|
+
env = process.env,
|
|
10
|
+
resolve = resolveExecutable,
|
|
11
|
+
launchShape = spawnArgv,
|
|
12
|
+
childEnv = childEnvironment,
|
|
13
|
+
} = {}) {
|
|
14
|
+
async function prepare(client) {
|
|
15
|
+
if (client?.transport !== 'stdio') return { client: structuredClone(client), execution: null }
|
|
16
|
+
const requestedCommand = client.command
|
|
17
|
+
const resolvedCommand = await resolve(subprocess, requestedCommand)
|
|
18
|
+
if (!resolvedCommand) {
|
|
19
|
+
throw new McpLifecycleError('executable-not-found', new Error(`MCP executable "${requestedCommand}" was not found on the GUI PATH or known install directories`), {
|
|
20
|
+
status: 'validation-failed',
|
|
21
|
+
action: 'fix-command',
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
const originalArgs = [...(client.args ?? [])]
|
|
25
|
+
const argv = launchShape(resolvedCommand, originalArgs, platform, { subject: 'mcp', setting: 'command' })
|
|
26
|
+
const prepared = {
|
|
27
|
+
...structuredClone(client),
|
|
28
|
+
command: argv[0],
|
|
29
|
+
args: argv.slice(1),
|
|
30
|
+
env: { ...childEnv(env, platform), ...(client.env ?? {}) },
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
client: prepared,
|
|
34
|
+
execution: {
|
|
35
|
+
requestedCommand,
|
|
36
|
+
resolvedCommand,
|
|
37
|
+
argumentCount: originalArgs.length,
|
|
38
|
+
windowsShim: platform === 'win32' && argv[0] !== resolvedCommand,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { prepare }
|
|
43
|
+
}
|
|
@@ -1,42 +1,38 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// decisions read ONLY these fields — never a public `mcp__...` tool name
|
|
6
|
-
// (name strings can collide, G3b `collide` EXECUTED) and never an
|
|
7
|
-
// authorization inherited from a display label.
|
|
1
|
+
// Weir-managed MCP registry: the sole configuration authority. Incarnation binds
|
|
2
|
+
// session authorization to one registration lifetime; enabled is independent of
|
|
3
|
+
// every session's draft/accepted selection and workspace-default records.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
8
5
|
import { isSegment } from './store/paths.js'
|
|
9
6
|
|
|
10
7
|
export const MCP_REGISTRY_SCHEMA = 1
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @typedef {{ identity: string, label: string, owner: { kind: 'workspace'|'global', key: string },
|
|
14
|
-
* generation: number, configuredAt: number, transport: { kind: string, ref: string } }} McpServerEntry
|
|
15
|
-
* @typedef {{ servers: Record<string, McpServerEntry> }} McpRegistryPayload
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
8
|
const message = error => (error instanceof Error ? error.message : String(error))
|
|
9
|
+
const clone = value => value == null ? value : structuredClone(value)
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
export class McpRegistryError extends Error {
|
|
12
|
+
constructor(code, text) {
|
|
13
|
+
super(text)
|
|
14
|
+
this.name = 'McpRegistryError'
|
|
15
|
+
this.code = code
|
|
16
|
+
}
|
|
17
|
+
}
|
|
26
18
|
|
|
19
|
+
export function createMcpRegistry({ store, now = Date.now, newIncarnation = () => randomUUID() }) {
|
|
20
|
+
const unit = { kind: 'mcp-registry' }
|
|
27
21
|
const normalize = payload => {
|
|
28
|
-
const servers = payload && typeof payload === 'object' && !Array.isArray(payload) && payload.servers && typeof payload.servers === 'object'
|
|
29
|
-
? payload.servers
|
|
22
|
+
const servers = payload && typeof payload === 'object' && !Array.isArray(payload) && payload.servers && typeof payload.servers === 'object' && !Array.isArray(payload.servers)
|
|
23
|
+
? clone(payload.servers)
|
|
30
24
|
: {}
|
|
31
|
-
|
|
25
|
+
const enabled = [...new Set((Array.isArray(payload?.enabled) ? payload.enabled : []).filter(identity => typeof identity === 'string' && isSegment(identity)))]
|
|
26
|
+
return { servers, enabled }
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
/** @returns {Promise<{ kind: 'absent' } | { kind: 'ok', revision: number, servers: Record<string, McpServerEntry> } | { kind: string }>} */
|
|
35
29
|
async function read() {
|
|
36
30
|
const record = await store.read(unit)
|
|
37
|
-
if (record.kind === 'absent') return { kind: 'absent' }
|
|
31
|
+
if (record.kind === 'absent') return { kind: 'absent', enabled: [], warnings: [] }
|
|
38
32
|
if (record.kind !== 'ok') return { kind: record.kind }
|
|
39
|
-
|
|
33
|
+
const { servers, enabled } = normalize(record.payload)
|
|
34
|
+
const warnings = enabled.filter(identity => !servers[identity]).map(identity => ({ code: 'default-missing', identity }))
|
|
35
|
+
return { kind: 'ok', revision: record.revision, servers, enabled, warnings }
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
const validateIdentity = identity => {
|
|
@@ -47,68 +43,86 @@ export function createMcpRegistry({ store, now = Date.now }) {
|
|
|
47
43
|
if (typeof entry.label !== 'string' || entry.label.length === 0) throw new TypeError(`invalid MCP server label for "${entry.identity}"`)
|
|
48
44
|
if (entry.owner?.kind !== 'workspace' && entry.owner?.kind !== 'global') throw new TypeError(`invalid MCP server owner for "${entry.identity}"`)
|
|
49
45
|
if (!isSegment(entry.owner.key)) throw new TypeError(`invalid MCP server owner key for "${entry.identity}"`)
|
|
50
|
-
if (entry.transport?.kind
|
|
46
|
+
if (!['stdio', 'streamable-http'].includes(entry.transport?.kind)) throw new TypeError(`invalid MCP server transport for "${entry.identity}"`)
|
|
51
47
|
if (typeof entry.transport.ref !== 'string' || entry.transport.ref.length === 0) throw new TypeError(`invalid MCP server transport ref for "${entry.identity}"`)
|
|
48
|
+
if (entry.incarnation !== undefined && entry.incarnation !== null && (typeof entry.incarnation !== 'string' || entry.incarnation.length === 0)) throw new TypeError(`invalid MCP server incarnation for "${entry.identity}"`)
|
|
52
49
|
}
|
|
50
|
+
const committed = (result, extra = {}) => ({ ...result, ...extra })
|
|
53
51
|
|
|
54
|
-
/**
|
|
55
|
-
* Register a new managed server (fresh generation 1) or re-register the
|
|
56
|
-
* same configured identity under a NEW registration generation (reconnect).
|
|
57
|
-
* A display-name change of the SAME identity keeps the identity and bumps
|
|
58
|
-
* the generation — authorization never inherits across identities.
|
|
59
|
-
* @param {McpServerEntry} entry @param {number} expectedRevision
|
|
60
|
-
*/
|
|
61
52
|
async function register(entry, expectedRevision) {
|
|
62
53
|
validateEntry(entry)
|
|
63
|
-
|
|
64
|
-
const { servers } = normalize(payload)
|
|
54
|
+
const result = await store.commit(unit, expectedRevision, payload => {
|
|
55
|
+
const { servers, enabled } = normalize(payload)
|
|
65
56
|
const existing = servers[entry.identity]
|
|
66
57
|
servers[entry.identity] = {
|
|
67
|
-
...entry,
|
|
58
|
+
...clone(entry),
|
|
59
|
+
// A legacy registration remains legacy across edit/restart so legacy
|
|
60
|
+
// selections keep working. Creation/re-add gets a fresh incarnation.
|
|
61
|
+
incarnation: existing ? (existing.incarnation ?? null) : newIncarnation(),
|
|
68
62
|
generation: (existing?.generation ?? 0) + 1,
|
|
69
63
|
configuredAt: existing?.configuredAt ?? now(),
|
|
70
64
|
}
|
|
71
|
-
return { servers }
|
|
65
|
+
return { servers, enabled }
|
|
72
66
|
})
|
|
67
|
+
return committed(result, { incarnation: result.status === 'committed' && result.payload?.servers?.[entry.identity]?.incarnation !== undefined ? result.payload.servers[entry.identity].incarnation : undefined })
|
|
73
68
|
}
|
|
74
69
|
|
|
75
|
-
/** @param {string} identity @param {number} expectedRevision */
|
|
76
70
|
async function remove(identity, expectedRevision) {
|
|
77
71
|
validateIdentity(identity)
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
let defaultRemoved = false
|
|
73
|
+
const result = await store.commit(unit, expectedRevision, payload => {
|
|
74
|
+
const { servers, enabled } = normalize(payload)
|
|
80
75
|
delete servers[identity]
|
|
81
|
-
|
|
76
|
+
const nextEnabled = enabled.filter(item => item !== identity)
|
|
77
|
+
defaultRemoved = nextEnabled.length !== enabled.length
|
|
78
|
+
return { servers, enabled: nextEnabled }
|
|
82
79
|
})
|
|
80
|
+
return committed(result, { defaultRemoved: result.status === 'committed' && defaultRemoved })
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
/** Rename = a NEW identity (generation 1) plus removal of the old one, in ONE atomic commit. */
|
|
86
83
|
async function rename(from, to, entry, expectedRevision) {
|
|
87
84
|
validateIdentity(from)
|
|
85
|
+
validateIdentity(to)
|
|
88
86
|
validateEntry({ ...entry, identity: to })
|
|
89
|
-
|
|
90
|
-
const { servers } = normalize(payload)
|
|
87
|
+
const result = await store.commit(unit, expectedRevision, payload => {
|
|
88
|
+
const { servers, enabled } = normalize(payload)
|
|
91
89
|
delete servers[from]
|
|
92
|
-
servers[to] = { ...entry, identity: to, generation: 1, configuredAt: now() }
|
|
93
|
-
|
|
90
|
+
servers[to] = { ...clone(entry), identity: to, incarnation: newIncarnation(), generation: 1, configuredAt: now() }
|
|
91
|
+
const wasDefault = enabled.includes(from)
|
|
92
|
+
return { servers, enabled: [...new Set([...enabled.filter(identity => identity !== from), ...(wasDefault ? [to] : [])])] }
|
|
93
|
+
})
|
|
94
|
+
return committed(result)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function setDefaults(identities, expectedRevision) {
|
|
98
|
+
const requested = [...new Set(identities)]
|
|
99
|
+
for (const identity of requested) validateIdentity(identity)
|
|
100
|
+
const result = await store.commit(unit, expectedRevision, payload => {
|
|
101
|
+
const { servers } = normalize(payload)
|
|
102
|
+
for (const identity of requested) if (!servers[identity]) throw new McpRegistryError('identity-unregistered', `cannot enable unregistered MCP default "${identity}"`)
|
|
103
|
+
return { servers, enabled: requested }
|
|
104
|
+
})
|
|
105
|
+
return committed(result, { enabled: result.status === 'committed' ? requested : undefined })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function setDefault(identity, enabled, expectedRevision) {
|
|
109
|
+
validateIdentity(identity)
|
|
110
|
+
const result = await store.commit(unit, expectedRevision, payload => {
|
|
111
|
+
const current = normalize(payload)
|
|
112
|
+
if (!current.servers[identity]) throw new McpRegistryError('identity-unregistered', `cannot change unregistered MCP default "${identity}"`)
|
|
113
|
+
const defaults = new Set(current.enabled)
|
|
114
|
+
if (enabled) defaults.add(identity)
|
|
115
|
+
else defaults.delete(identity)
|
|
116
|
+
return { servers: current.servers, enabled: [...defaults] }
|
|
94
117
|
})
|
|
118
|
+
return committed(result, { defaultEnabled: result.status === 'committed' ? Boolean(enabled) : undefined })
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
return { read, register, remove, rename, unit }
|
|
121
|
+
return { read, register, remove, rename, setDefault, setDefaults, unit }
|
|
98
122
|
}
|
|
99
123
|
|
|
100
|
-
/**
|
|
101
|
-
* The admission predicate (8.1): derives its verdict from registry fields
|
|
102
|
-
* ONLY — configured identity + registration generation. It never receives,
|
|
103
|
-
* parses or compares a public tool name.
|
|
104
|
-
* @param {{ read(): Promise<any> }} registry
|
|
105
|
-
*/
|
|
124
|
+
/** Legacy admission helper: identity + generation only, never public names. */
|
|
106
125
|
export function createMcpAdmission(registry) {
|
|
107
|
-
/**
|
|
108
|
-
* @param {string} identity - configured identity established at facade registration time
|
|
109
|
-
* @param {number} generation - the registration generation that facade instance was mounted with
|
|
110
|
-
* @returns {Promise<{ admitted: boolean, reason: string|null }>}
|
|
111
|
-
*/
|
|
112
126
|
async function admit(identity, generation) {
|
|
113
127
|
const current = await registry.read()
|
|
114
128
|
if (current.kind !== 'ok') return { admitted: false, reason: current.kind === 'absent' ? 'registry-absent' : `registry-${current.kind}` }
|
|
@@ -120,4 +134,4 @@ export function createMcpAdmission(registry) {
|
|
|
120
134
|
return { admit }
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
export const __message = message
|
|
137
|
+
export const __message = message
|