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.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +3 -3
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +368 -142
  24. package/src/capabilities/mcp-mount.js +219 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +161 -56
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. 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
- // Task 8.1 of the session-capability-manager change: the Weir-managed MCP
2
- // server registry. The configured identity, scope owner and registration
3
- // generation of every Weir-managed server live here, persisted through the
4
- // group-2 store as the independent { kind: 'mcp-registry' } unit. Admission
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
- * @param {{ store: { read(unit: unknown): Promise<any>, commit(unit: unknown, expectedRevision: number, mutate: (payload: unknown) => unknown, request?: unknown): Promise<any> },
22
- * now?: () => number }} options
23
- */
24
- export function createMcpRegistry({ store, now = Date.now }) {
25
- const unit = { kind: 'mcp-registry' }
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
- return { servers: { ...servers } }
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
- return { kind: 'ok', revision: record.revision, ...normalize(record.payload) }
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 !== 'stdio') throw new TypeError(`invalid MCP server transport for "${entry.identity}": only stdio is supported`)
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
- return store.commit(unit, expectedRevision, payload => {
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
- return store.commit(unit, expectedRevision, payload => {
79
- const { servers } = normalize(payload)
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
- return { servers }
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
- return store.commit(unit, expectedRevision, payload => {
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
- return { servers }
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 // test seam
137
+ export const __message = message