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,169 @@
|
|
|
1
|
+
// Secret-free, bounded MCP diagnostics. Raw Error objects, stacks, URLs with
|
|
2
|
+
// credentials, and arbitrary cause chains never cross the operation boundary.
|
|
3
|
+
export const MCP_DIAGNOSTIC_LIMIT = 8192
|
|
4
|
+
const CAUSE_LIMIT = 6
|
|
5
|
+
|
|
6
|
+
const MESSAGE = value => value instanceof Error ? value.message : String(value ?? 'unknown error')
|
|
7
|
+
|
|
8
|
+
/** Known-secret metadata is internal, inherited through wrapped causes, and never enumerated on the wire. */
|
|
9
|
+
function errorSecrets(error, initial = []) {
|
|
10
|
+
const secrets = [...initial]
|
|
11
|
+
let current = error
|
|
12
|
+
for (let depth = 0; current && depth < CAUSE_LIMIT; depth += 1) {
|
|
13
|
+
if (Array.isArray(current?.secrets)) secrets.push(...current.secrets)
|
|
14
|
+
current = current?.cause
|
|
15
|
+
}
|
|
16
|
+
return secrets
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Replace known secrets, then reduce sensitive URL tails to scheme/host/path. */
|
|
20
|
+
function redactKnownAndUrls(text, secrets) {
|
|
21
|
+
const known = [...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length > 0))]
|
|
22
|
+
.sort((a, b) => b.length - a.length)
|
|
23
|
+
for (const secret of known) text = text.split(secret).join('[redacted]')
|
|
24
|
+
// Any sensitive URL component makes the whole userinfo/query/fragment tail
|
|
25
|
+
// sensitive. Preserve only scheme/host/path for actionable diagnostics.
|
|
26
|
+
return text.replace(/\b(?:https?|wss?):\/\/[^\s<>"']+/gi, candidate => {
|
|
27
|
+
const trailing = candidate.match(/[.,;:!?)]*$/)?.[0] ?? ''
|
|
28
|
+
const raw = trailing ? candidate.slice(0, -trailing.length) : candidate
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(raw)
|
|
31
|
+
if (!url.username && !url.password && !url.search && !url.hash) return candidate
|
|
32
|
+
return `${url.protocol}//${url.host}${url.pathname}[redacted-url]${trailing}`
|
|
33
|
+
} catch {
|
|
34
|
+
return '[redacted-url]'
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Free text: replace every known secret before applying pattern-based redaction. */
|
|
40
|
+
export function sanitizeMcpText(value, secrets = []) {
|
|
41
|
+
let text = String(value ?? '')
|
|
42
|
+
const known = [...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length > 0))]
|
|
43
|
+
.sort((a, b) => b.length - a.length)
|
|
44
|
+
for (const secret of known) text = text.split(secret).join('[redacted]')
|
|
45
|
+
// Authorization lines and common secret-bearing key/value spellings.
|
|
46
|
+
text = text.replace(/\b(?:proxy-)?authorization\s*:\s*[^\r\n,;}]+/gi, match => `${match.split(':')[0]}: [redacted]`)
|
|
47
|
+
text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
|
|
48
|
+
text = text.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted-jwt]')
|
|
49
|
+
text = text.replace(/\b(?:api[-_]?key|access[-_]?token|refresh[-_]?token|password|passwd|secret|client[-_]?secret)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, (_match, key, separator) => `${key}${separator}[redacted]`)
|
|
50
|
+
// Shell/environment assignments. Values may be quoted; the key remains useful.
|
|
51
|
+
text = text.replace(/\b[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)/g, assignment => `${assignment.slice(0, assignment.indexOf('='))}=[redacted]`)
|
|
52
|
+
return redactKnownAndUrls(text, [])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Structured MCP configuration fields (argv, command, cwd, label, refs) are
|
|
57
|
+
* exact values the user must be able to read, edit and approve. Secret-bearing
|
|
58
|
+
* positions are already replaced structurally (secretRefs-indexed args, env/
|
|
59
|
+
* header values, sensitive URLs); here only known secret values and sensitive
|
|
60
|
+
* URL tails are removed. The generic KEY=VALUE patterns are for free text only:
|
|
61
|
+
* applying them to argv turned `--port=8080` into `--port=[redacted]`.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeMcpConfigText(value, secrets = []) {
|
|
64
|
+
return redactKnownAndUrls(String(value ?? ''), secrets)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Config projections produced by redactMcpEntry. Membership is process-local and
|
|
68
|
+
// lost on clone/serialization, which only falls back to the stricter free-text
|
|
69
|
+
// sanitizer — never to less redaction.
|
|
70
|
+
const CONFIG_PROJECTIONS = new WeakSet()
|
|
71
|
+
export function markMcpConfigProjection(value) {
|
|
72
|
+
if (value && typeof value === 'object') CONFIG_PROJECTIONS.add(value)
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
function sanitizeConfigProjection(value, secrets) {
|
|
76
|
+
if (typeof value === 'string') return sanitizeMcpConfigText(value, secrets)
|
|
77
|
+
if (Array.isArray(value)) return value.map(item => sanitizeConfigProjection(item, secrets))
|
|
78
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeConfigProjection(item, secrets)]))
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sanitizeMcpPayload(value, secrets = []) {
|
|
83
|
+
if (typeof value === 'string') return sanitizeMcpText(value, secrets)
|
|
84
|
+
if (Array.isArray(value)) return value.map(item => sanitizeMcpPayload(item, secrets))
|
|
85
|
+
if (value && typeof value === 'object') {
|
|
86
|
+
if (CONFIG_PROJECTIONS.has(value)) return markMcpConfigProjection(sanitizeConfigProjection(value, secrets))
|
|
87
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeMcpPayload(item, secrets)]))
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const RULES = [
|
|
93
|
+
['credential-missing', /credential[- ]missing|credential.*(?:not configured|empty|missing)|missing.*credential/i, 'Configure a non-empty credential reference, then retry.'],
|
|
94
|
+
['name-conflict', /serverName.*(?:already in use|conflict)|name[- ]conflict|namespace.*conflict/i, 'Choose a unique public server name or remove the conflicting registration.'],
|
|
95
|
+
['executable-not-found', /\bENOENT\b|executable.*(?:not found|unavailable)|command.*not found|cannot find.*(?:command|executable)/i, 'Check the command and its GUI PATH resolution, then retry.'],
|
|
96
|
+
['permission-denied', /\bEACCES\b|\bEPERM\b|permission denied|access denied/i, 'Check executable and credential/file permissions, then retry.'],
|
|
97
|
+
['connection-refused', /ECONNREFUSED|connection refused/i, 'Check that the server is listening and the endpoint is correct, then retry.'],
|
|
98
|
+
['http-unauthorized', /\bHTTP\s*40[13]\b|\b40[13]\b|unauthorized|forbidden/i, 'Check the HTTP credential reference and server authorization policy.'],
|
|
99
|
+
['timeout', /timeout|timed out|deadline exceeded/i, 'Inspect server startup and connectivity, then retry with a healthy server.'],
|
|
100
|
+
['invalid-config', /invalid config|validation|unsupported transport|unknown (?:option|field)|initialDelayMs|maxDelayMs/i, 'Correct the marked configuration field and preview again.'],
|
|
101
|
+
['cleanup-unknown', /cleanup-unknown|closure could not be confirmed|unconfirmed (?:transport )?clos/i, 'Inspect residue before retrying; an immediate same-name replacement is blocked.'],
|
|
102
|
+
['cleanup-failed', /cleanup-failed|dispose(?:d|al)?(?: rejection| failed)|remove rejection/i, 'Inspect the cleanup diagnostic and recover the remaining Loader entry before retrying.'],
|
|
103
|
+
['unsupported', /unsupported|ephemeral.*unavailable/i, 'Use a secret-free configuration or a Host with the required ephemeral Loader support.'],
|
|
104
|
+
['owner-changed', /owner-changed|preset.*changed|ownership.*changed/i, 'Return to the original session/preset and preview again.'],
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
export function classifyMcpError(error, secrets = []) {
|
|
108
|
+
secrets = errorSecrets(error, secrets)
|
|
109
|
+
const evidence = []
|
|
110
|
+
let current = error
|
|
111
|
+
for (let depth = 0; current && depth < CAUSE_LIMIT; depth += 1) {
|
|
112
|
+
evidence.push(`${current?.code ?? ''} ${MESSAGE(current)}`)
|
|
113
|
+
current = current?.cause
|
|
114
|
+
}
|
|
115
|
+
const text = sanitizeMcpText(evidence.join('\n'), secrets)
|
|
116
|
+
const match = RULES.find(([, pattern]) => pattern.test(text))
|
|
117
|
+
if (!match) return { code: 'unknown-error', hint: 'Inspect the sanitized diagnostic and retry after correcting the cause.' }
|
|
118
|
+
return { code: match[0], hint: match[2] }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function boundedMessage(text, max = 4096) {
|
|
122
|
+
const clean = String(text)
|
|
123
|
+
return Buffer.byteLength(clean) <= max ? clean : Buffer.from(clean).subarray(0, max - 1).toString().replace(/\uFFFD+$/g, '') + '…'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Convert an Error/cause chain into one stable secret-free diagnostic. Both
|
|
128
|
+
* individual strings and the complete JSON projection are bounded to 8 KiB.
|
|
129
|
+
*/
|
|
130
|
+
export function mcpDiagnostic(error, options = {}) {
|
|
131
|
+
const secrets = errorSecrets(error, options.secrets ?? [])
|
|
132
|
+
const classified = options.code ? { code: options.code, hint: options.hint } : classifyMcpError(error, secrets)
|
|
133
|
+
const causes = []
|
|
134
|
+
let current = error
|
|
135
|
+
for (let depth = 0; current && depth < CAUSE_LIMIT; depth += 1) {
|
|
136
|
+
causes.push(boundedMessage(sanitizeMcpText(MESSAGE(current), secrets), 1024))
|
|
137
|
+
current = current?.cause
|
|
138
|
+
}
|
|
139
|
+
const first = causes[0] ?? boundedMessage(sanitizeMcpText(MESSAGE(error), secrets), 1024)
|
|
140
|
+
let diagnostic = {
|
|
141
|
+
code: classified.code ?? 'unknown-error',
|
|
142
|
+
message: boundedMessage(sanitizeMcpText(options.message ?? first, secrets)),
|
|
143
|
+
hint: boundedMessage(sanitizeMcpText(options.hint ?? classified.hint ?? 'Retry after correcting the cause.'), 1024),
|
|
144
|
+
recovery: { action: options.action ?? 'retry' },
|
|
145
|
+
causes,
|
|
146
|
+
}
|
|
147
|
+
while (Buffer.byteLength(JSON.stringify(diagnostic)) > MCP_DIAGNOSTIC_LIMIT && diagnostic.causes.length > 1) {
|
|
148
|
+
diagnostic.causes.pop()
|
|
149
|
+
}
|
|
150
|
+
if (Buffer.byteLength(JSON.stringify(diagnostic)) > MCP_DIAGNOSTIC_LIMIT) {
|
|
151
|
+
diagnostic.message = boundedMessage(diagnostic.message, 2048)
|
|
152
|
+
diagnostic.hint = boundedMessage(diagnostic.hint, 512)
|
|
153
|
+
}
|
|
154
|
+
return diagnostic
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class McpLifecycleError extends Error {
|
|
158
|
+
constructor(code, error, options = {}) {
|
|
159
|
+
const secrets = errorSecrets(error, options.secrets ?? [])
|
|
160
|
+
const diagnostic = mcpDiagnostic(error, { ...options, secrets, code, hint: options.hint })
|
|
161
|
+
super(diagnostic.message, options.cause === undefined && error instanceof Error ? { cause: error } : {})
|
|
162
|
+
this.name = 'McpLifecycleError'
|
|
163
|
+
this.code = code
|
|
164
|
+
this.status = options.status ?? code
|
|
165
|
+
this.diagnostic = diagnostic
|
|
166
|
+
Object.defineProperty(this, 'secrets', { value: Object.freeze([...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length > 0))]), enumerable: false })
|
|
167
|
+
this.cleanup = options.cleanup === undefined ? undefined : sanitizeMcpPayload(options.cleanup, secrets)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
15
15
|
/** @type {Map<string, { count: number, calls: Map<number, string>, closed: boolean }>} */
|
|
16
16
|
const flights = new Map()
|
|
17
|
+
const closedServers = new Set()
|
|
17
18
|
let sequence = 0
|
|
18
19
|
|
|
19
20
|
const keyOf = (agentId, server) => `${agentId ?? 'unknown'}${server}`
|
|
20
21
|
const stateOf = (agentId, server) => {
|
|
21
22
|
const key = keyOf(agentId, server)
|
|
22
|
-
if (!flights.has(key)) flights.set(key, { count: 0, calls: new Map(), closed: false })
|
|
23
|
+
if (!flights.has(key)) flights.set(key, { server, count: 0, calls: new Map(), closed: false })
|
|
23
24
|
return flights.get(key)
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -30,7 +31,7 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
30
31
|
*/
|
|
31
32
|
function enter(agentId, server, label = 'call') {
|
|
32
33
|
const state = stateOf(agentId, server)
|
|
33
|
-
if (state.closed) return null
|
|
34
|
+
if (state.closed || closedServers.has(server)) return null
|
|
34
35
|
sequence += 1
|
|
35
36
|
state.count += 1
|
|
36
37
|
state.calls.set(sequence, label)
|
|
@@ -68,7 +69,7 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function isClosed(agentId, server) {
|
|
71
|
-
return stateOf(agentId, server).closed
|
|
72
|
+
return stateOf(agentId, server).closed || closedServers.has(server)
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** The Apply-time snapshot the response carries (sent at response time). */
|
|
@@ -77,7 +78,20 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
77
78
|
return { server, state: state.count === 0 ? 'settled' : 'draining', inFlight: state.count }
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
function closeAll(server) { closedServers.add(server) }
|
|
82
|
+
function reopenAll(server) { closedServers.delete(server) }
|
|
83
|
+
async function drainAll(server) {
|
|
84
|
+
const states = [...flights.values()].filter(state => state.server === server)
|
|
85
|
+
const started = now()
|
|
86
|
+
const totals = () => ({ count: states.reduce((sum, state) => sum + state.count, 0), calls: states.flatMap(state => [...state.calls.values()]) })
|
|
87
|
+
while (totals().count > 0 && now() - started < timeoutMs) await sleep(Math.min(50, timeoutMs))
|
|
88
|
+
const current = totals()
|
|
89
|
+
return current.count === 0
|
|
90
|
+
? { drained: true, waitedMs: now() - started }
|
|
91
|
+
: { drained: false, inFlight: current.count, calls: current.calls, waitedMs: now() - started }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { enter, exit, close, reopen, isClosed, drain, snapshot, closeAll, reopenAll, drainAll }
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
/**
|
|
@@ -32,8 +32,8 @@ const message = error => (error instanceof Error ? error.message : String(error)
|
|
|
32
32
|
* drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
|
|
33
33
|
* onError?: (reason: string, cause?: unknown) => void }} config
|
|
34
34
|
*/
|
|
35
|
-
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {} }) {
|
|
36
|
-
const wrapper = createMcpToolWrapper({ identity, generation, admit, drain })
|
|
35
|
+
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {}, assertPermit }) {
|
|
36
|
+
const wrapper = createMcpToolWrapper({ identity, generation, admit, drain, assertPermit })
|
|
37
37
|
|
|
38
38
|
const unknown = (service, method) => {
|
|
39
39
|
const reason = `facade-unknown-method:${service}.${method}`
|
|
@@ -91,7 +91,7 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
91
91
|
const gated = typeof request === 'function'
|
|
92
92
|
? { ...provider, async request(args, exec) {
|
|
93
93
|
const admitted = await admit(exec?.agent)
|
|
94
|
-
if (!admitted) {
|
|
94
|
+
if (!admitted || (assertPermit && !assertPermit(admitted))) {
|
|
95
95
|
const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
|
|
96
96
|
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
97
97
|
}
|
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// 1. the registry's configured identity + current generation (8.1) — a
|
|
5
|
-
// reconnect mounts a NEW generation and every old-generation handle is
|
|
6
|
-
// rejected; a same-named DIFFERENT identity inherits nothing (8.5);
|
|
7
|
-
// 2. the session lifecycle snapshot: BLOCKED sessions refuse every managed
|
|
8
|
-
// call (6.4); a dispose/reload window without an initialized snapshot
|
|
9
|
-
// refuses too (8.5);
|
|
10
|
-
// 3. the session's accepted managed-MCP set (the selection record's
|
|
11
|
-
// mcpServers): a server not in the set is not enabled for that agent.
|
|
12
|
-
// Connections stay shared at the host plane — a session losing a server
|
|
13
|
-
// never disposes anything (8.5).
|
|
1
|
+
// Managed MCP admission combines runtime registration generation/incarnation,
|
|
2
|
+
// the accepted lifecycle selection, and (when mounted) the per-identity runtime
|
|
3
|
+
// phase. Legacy compatibility exists only when BOTH sides lack incarnations.
|
|
14
4
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
export function mcpIncarnationMatches(snapshot, identity, entry) {
|
|
6
|
+
if (!entry) return false
|
|
7
|
+
const mapping = snapshot?.mcpIncarnations
|
|
8
|
+
if (mapping && typeof mapping === 'object' && !Array.isArray(mapping)) {
|
|
9
|
+
if (!Object.hasOwn(mapping, identity)) return false
|
|
10
|
+
return mapping[identity] === (entry.incarnation ?? null)
|
|
11
|
+
}
|
|
12
|
+
// A legacy selection without a mapping remains valid only for a legacy entry.
|
|
13
|
+
return entry.incarnation == null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function acceptedSnapshot(lifecycle, agent, { parentFallback = false } = {}) {
|
|
17
|
+
const sessionId = agent?.session?.id
|
|
18
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
|
|
19
|
+
let snapshot = lifecycle.snapshotFor(sessionId)
|
|
20
|
+
if (snapshot == null && parentFallback) {
|
|
21
|
+
const parentId = agent?.session?.header?.parentSession
|
|
22
|
+
if (typeof parentId === 'string' && parentId.length > 0) snapshot = lifecycle.snapshotFor(parentId)
|
|
23
|
+
}
|
|
24
|
+
return snapshot?.state === 'ready' ? snapshot : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createMcpGate({ registry, lifecycle, sessionMcpServers, registrationFor, runtime }) {
|
|
21
28
|
const acceptedOf = sessionId => {
|
|
22
29
|
if (typeof sessionMcpServers === 'function') return sessionMcpServers(sessionId)
|
|
23
30
|
const snapshot = lifecycle.snapshotFor(sessionId)
|
|
@@ -25,47 +32,39 @@ export function createMcpGate({ registry, lifecycle, sessionMcpServers }) {
|
|
|
25
32
|
return snapshot.mcpServers
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
* The facade-side verdict for one call.
|
|
30
|
-
* @param {unknown} agent @param {string} identity @param {number} generation
|
|
31
|
-
* @returns {Promise<boolean>}
|
|
32
|
-
*/
|
|
33
|
-
async function admit(agent, identity, generation) {
|
|
35
|
+
async function permit(agent, identity, generation) {
|
|
34
36
|
const sessionId = agent?.session?.id
|
|
35
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (snapshot?.state !== 'ready') return false
|
|
37
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
|
|
38
|
+
if (runtime?.enabledFor && runtime.enabledFor(identity) === false) return null
|
|
39
|
+
const snapshot = acceptedSnapshot(lifecycle, agent)
|
|
40
|
+
if (!snapshot) return null
|
|
40
41
|
const accepted = acceptedOf(sessionId)
|
|
41
|
-
if (!Array.isArray(accepted) || !accepted.includes(identity)) return
|
|
42
|
+
if (!Array.isArray(accepted) || !accepted.includes(identity)) return null
|
|
43
|
+
if (runtime?.beforeRead && runtime.beforeRead(identity) === false) return null
|
|
42
44
|
const current = await registry.read()
|
|
43
|
-
if (current.kind !== 'ok') return
|
|
45
|
+
if (current.kind !== 'ok') return null
|
|
44
46
|
const entry = current.servers[identity]
|
|
45
|
-
|
|
47
|
+
if (!entry || entry.generation !== generation || !mcpIncarnationMatches(snapshot, identity, entry)) return null
|
|
48
|
+
if (runtime?.afterRead && runtime.afterRead(identity) === false) return null
|
|
49
|
+
if (runtime?.permit) return runtime.permit(identity, { generation, incarnation: entry.incarnation ?? null })
|
|
50
|
+
return { identity, generation, incarnation: entry.incarnation ?? null, operationId: null }
|
|
51
|
+
}
|
|
52
|
+
async function admit(agent, identity, generation) {
|
|
53
|
+
return (await permit(agent, identity, generation)) !== null
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
/**
|
|
49
|
-
* Instruction-section filtering (8.4): is the server in the session's
|
|
50
|
-
* accepted set? At a subagent's creation the inherited capture is what the
|
|
51
|
-
* child gets — the parent's accepted set VERBATIM at that moment — so when
|
|
52
|
-
* the child's own snapshot is not visible yet (the creation-time
|
|
53
|
-
* schema-hiding listener races the capture listener), the parent's
|
|
54
|
-
* snapshot stands in. Call admission (admit) never uses this fallback: a
|
|
55
|
-
* snapshot-less child is refused, never granted the parent's set.
|
|
56
|
-
*/
|
|
57
56
|
function enabledFor(agent, identity) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
if (runtime?.enabledFor && runtime.enabledFor(identity) === false) return false
|
|
58
|
+
const snapshot = acceptedSnapshot(lifecycle, agent, { parentFallback: true })
|
|
59
|
+
if (!snapshot || !Array.isArray(snapshot.mcpServers) || !snapshot.mcpServers.includes(identity)) return false
|
|
60
|
+
if (typeof registrationFor !== 'function') {
|
|
61
|
+
// Pure legacy tests without a runtime registry still get mapping-shape
|
|
62
|
+
// enforcement; production always supplies registrationFor below.
|
|
63
|
+
const mapping = snapshot.mcpIncarnations
|
|
64
|
+
return !(mapping && typeof mapping === 'object' && !Array.isArray(mapping) && !Object.hasOwn(mapping, identity))
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
const accepted = snapshot.mcpServers
|
|
67
|
-
return Array.isArray(accepted) && accepted.includes(identity)
|
|
66
|
+
return mcpIncarnationMatches(snapshot, identity, registrationFor(identity))
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
return { admit, enabledFor }
|
|
69
|
+
return { admit, permit, enabledFor, assertPermit: value => runtime?.assertPermit ? runtime.assertPermit(value) : Boolean(value) }
|
|
71
70
|
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Primary-Orchestrator MCP installation. Preview is inert; install executes only
|
|
2
|
+
// the Host-stored plan approved through userQuestions, never post-answer model input.
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { parseMcpDocument, fingerprintMcpEntry } from './mcp-config.js'
|
|
5
|
+
import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
|
|
6
|
+
|
|
7
|
+
export const MCP_INSTALL_TOOL_NAME = 'mcp_install'
|
|
8
|
+
export const MCP_INSTALL_APPROVE_LABEL = 'Install MCP server'
|
|
9
|
+
export const MCP_INSTALL_CANCEL_LABEL = 'Cancel'
|
|
10
|
+
export const MCP_INSTALL_CONFIRMATION_TIMEOUT_MS = 5 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
const CREDENTIAL_REF = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
ref: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$', description: 'Existing DSH credential reference.' },
|
|
16
|
+
prefix: { type: 'string', description: 'Optional non-secret prefix, such as Bearer .' },
|
|
17
|
+
},
|
|
18
|
+
required: ['ref'], additionalProperties: false,
|
|
19
|
+
}
|
|
20
|
+
const REF_MAP = { type: 'object', additionalProperties: CREDENTIAL_REF }
|
|
21
|
+
const RECONNECT = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
enabled: { type: 'boolean' },
|
|
25
|
+
initialDelayMs: { type: 'number', exclusiveMinimum: 0 },
|
|
26
|
+
maxDelayMs: { type: 'number', exclusiveMinimum: 0 },
|
|
27
|
+
maxAttempts: { type: 'integer', minimum: 0 },
|
|
28
|
+
},
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MCP_INSTALL_PARAMETERS = {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
action: {
|
|
36
|
+
type: 'string', enum: ['preview', 'install'],
|
|
37
|
+
description: 'preview only validates and returns a redacted plan; install requests Host confirmation before any probe or write.',
|
|
38
|
+
},
|
|
39
|
+
plan: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
description: 'One fresh MCP server. Use credential references for every environment/header/secret-argument value and for any URL with userinfo, query, or fragment.',
|
|
42
|
+
properties: {
|
|
43
|
+
identity: { type: 'string', description: 'Fresh stable logical identity; installation never edits or replaces an existing registration.' },
|
|
44
|
+
label: { type: 'string' },
|
|
45
|
+
transport: { type: 'string', enum: ['stdio', 'streamable-http'] },
|
|
46
|
+
serverName: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,32}$' },
|
|
47
|
+
command: { type: 'string', description: 'stdio executable; required for stdio and forbidden for HTTP.' },
|
|
48
|
+
args: {
|
|
49
|
+
type: 'array', maxItems: 128,
|
|
50
|
+
description: 'Non-secret stdio arguments. Use a credential-reference object instead of a string for every sensitive argument.',
|
|
51
|
+
items: { anyOf: [{ type: 'string' }, CREDENTIAL_REF] },
|
|
52
|
+
},
|
|
53
|
+
secretArgs: { type: 'object', description: 'Credential references keyed by sensitive argument index.', additionalProperties: CREDENTIAL_REF },
|
|
54
|
+
env: { ...REF_MAP, description: 'stdio environment values as credential references; literal values are not accepted.' },
|
|
55
|
+
cwd: { type: 'string' },
|
|
56
|
+
url: {
|
|
57
|
+
description: 'Non-sensitive absolute HTTP(S) URL, or a credential reference for any URL containing userinfo, query, or fragment.',
|
|
58
|
+
anyOf: [{ type: 'string', pattern: '^https?://' }, CREDENTIAL_REF],
|
|
59
|
+
},
|
|
60
|
+
headers: { ...REF_MAP, description: 'HTTP header values as credential references; literal values are not accepted.' },
|
|
61
|
+
toolCallTimeoutMs: { type: 'number', exclusiveMinimum: 0 },
|
|
62
|
+
maxInstructionBytes: { type: 'integer', minimum: 1 },
|
|
63
|
+
reconnect: RECONNECT,
|
|
64
|
+
},
|
|
65
|
+
required: ['identity', 'transport'], additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['action', 'plan'], additionalProperties: false,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function failure(status, code, message, extra = {}) {
|
|
72
|
+
return {
|
|
73
|
+
status, saved: false,
|
|
74
|
+
error: mcpDiagnostic(new Error(message), { code, hint: code === 'DELEGATED_CALLER' ? 'Ask the parent Orchestrator to perform MCP setup.' : undefined }),
|
|
75
|
+
...extra,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function delegated(agent) {
|
|
80
|
+
return (agent?.session?.header?.delegationDepth ?? 0) >= 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function delegatedResult() {
|
|
84
|
+
return failure('unsupported', 'DELEGATED_CALLER', 'MCP installation belongs to the primary Orchestrator; delegate the setup request to the parent.', {
|
|
85
|
+
next: { action: 'delegate-to-parent-orchestrator' },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function mcpInstallApproved(answer, questionId, approveLabel = MCP_INSTALL_APPROVE_LABEL) {
|
|
90
|
+
if (!Array.isArray(answer?.answers) || answer.answers.length !== 1) return false
|
|
91
|
+
const item = answer.answers[0]
|
|
92
|
+
return item?.id === questionId && Array.isArray(item.selected) && item.selected.length === 1 && item.selected[0] === approveLabel && !item.custom
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertMcpInstallStoredPlan(stored) {
|
|
96
|
+
const digest = createHash('sha256').update(JSON.stringify(stored?.plan)).digest('hex')
|
|
97
|
+
if (digest !== stored?.digest || fingerprintMcpEntry(stored?.entry) !== stored?.entryFingerprint) {
|
|
98
|
+
throw Object.assign(new Error('The stored MCP installation plan changed while confirmation was open'), { code: 'plan-changed' })
|
|
99
|
+
}
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cancellation(execSignal, timeoutMs, setTimer, clearTimer) {
|
|
104
|
+
const controller = new AbortController()
|
|
105
|
+
const abort = () => controller.abort(execSignal?.reason)
|
|
106
|
+
if (execSignal?.aborted) abort()
|
|
107
|
+
else execSignal?.addEventListener?.('abort', abort, { once: true })
|
|
108
|
+
const timer = setTimer(() => controller.abort(Object.assign(new Error('MCP installation confirmation timed out'), { code: 'confirmation-timeout' })), timeoutMs)
|
|
109
|
+
return {
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
dispose() {
|
|
112
|
+
clearTimer(timer)
|
|
113
|
+
execSignal?.removeEventListener?.('abort', abort)
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function firstConfirmationSettlement(ask, signal) {
|
|
119
|
+
return new Promise(resolve => {
|
|
120
|
+
let settled = false
|
|
121
|
+
const finish = value => {
|
|
122
|
+
if (settled) return
|
|
123
|
+
settled = true
|
|
124
|
+
signal.removeEventListener('abort', aborted)
|
|
125
|
+
resolve(value)
|
|
126
|
+
}
|
|
127
|
+
const aborted = () => finish({ kind: 'cancelled', cause: signal.reason })
|
|
128
|
+
if (signal.aborted) { aborted(); return }
|
|
129
|
+
signal.addEventListener('abort', aborted, { once: true })
|
|
130
|
+
Promise.resolve().then(ask).then(
|
|
131
|
+
value => finish({ kind: 'answer', value }),
|
|
132
|
+
cause => finish({ kind: 'error', cause }),
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function confirmationFailure(cause) {
|
|
138
|
+
const code = cause?.code ?? 'confirmation-failed'
|
|
139
|
+
if (code === 'NO_PROVIDER') return failure('unsupported', code, 'Host user confirmation is unavailable; MCP installation was not attempted.')
|
|
140
|
+
if (code === 'DELEGATED_CALLER') return delegatedResult()
|
|
141
|
+
if (code === 'CALLER_NOT_LIVE') return failure('owner-changed', code, 'The calling agent is no longer the exact live runtime root; MCP installation was not attempted.')
|
|
142
|
+
return failure('cancelled', code, 'MCP installation confirmation was cancelled, closed, timed out, or failed; no probe or write occurred.')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function questionFor(stored, execution) {
|
|
146
|
+
const entry = stored.entry
|
|
147
|
+
const transport = entry.transport.kind
|
|
148
|
+
const effect = transport === 'stdio'
|
|
149
|
+
? `Process effect: run ${execution?.requestedCommand ?? entry.client.command} and perform MCP discovery.${execution?.resolvedCommand ? `\nResolved executable: ${execution.resolvedCommand}` : ''}`
|
|
150
|
+
: `Network effect: connect to ${entry.client.url ?? 'the credential-referenced endpoint'} and perform MCP discovery.`
|
|
151
|
+
const detail = [
|
|
152
|
+
`Plan digest: ${stored.digest}`,
|
|
153
|
+
effect,
|
|
154
|
+
...(transport === 'stdio' ? [`Arguments (exact argv; credential-referenced positions show [redacted]): ${JSON.stringify(stored.plan?.[0]?.entry?.client?.args ?? [])}`] : []),
|
|
155
|
+
'Default for new sessions: off. No session selection or Apply is changed.',
|
|
156
|
+
`Exact redacted plan:\n${JSON.stringify(stored.plan, null, 2)}`,
|
|
157
|
+
].join('\n')
|
|
158
|
+
return {
|
|
159
|
+
id: 'mcp-install-confirmation',
|
|
160
|
+
header: 'MCP server setup',
|
|
161
|
+
question: `Install MCP server "${stored.identity}" with exactly this plan?`,
|
|
162
|
+
detail,
|
|
163
|
+
options: [
|
|
164
|
+
{ label: MCP_INSTALL_APPROVE_LABEL, description: 'Probe first; only a successful probe may register and strictly mount this server.' },
|
|
165
|
+
{ label: MCP_INSTALL_CANCEL_LABEL, description: 'Do not probe, write credentials or registry records, or change selection.' },
|
|
166
|
+
],
|
|
167
|
+
multiSelect: false,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function previewConflict(preview) {
|
|
172
|
+
const target = preview?.targets?.[0]
|
|
173
|
+
return target?.conflict || target?.namespaceConflict || null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createMcpInstallTool(dependencies) {
|
|
177
|
+
const timeoutMs = dependencies.timeoutMs ?? MCP_INSTALL_CONFIRMATION_TIMEOUT_MS
|
|
178
|
+
const setTimer = dependencies.setTimeout ?? setTimeout
|
|
179
|
+
const clearTimer = dependencies.clearTimeout ?? clearTimeout
|
|
180
|
+
return {
|
|
181
|
+
name: MCP_INSTALL_TOOL_NAME,
|
|
182
|
+
description: 'Preview or install one fresh MCP server using credential references. Install requires exact-plan Host user confirmation, probes before registration, and never changes defaults or session selection.',
|
|
183
|
+
parameters: MCP_INSTALL_PARAMETERS,
|
|
184
|
+
output: {
|
|
185
|
+
schema: { type: 'object' },
|
|
186
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
187
|
+
},
|
|
188
|
+
async execute(args, exec) {
|
|
189
|
+
const agent = exec?.agent
|
|
190
|
+
const session = agent?.session
|
|
191
|
+
if (delegated(agent)) return delegatedResult()
|
|
192
|
+
if (!session || !['preview', 'install'].includes(args?.action)) return failure('validation-failed', 'invalid-config', 'mcp_install requires a live session and action preview or install.')
|
|
193
|
+
|
|
194
|
+
let mutationStarted = false
|
|
195
|
+
try {
|
|
196
|
+
const operations = dependencies.operationsFor(session)
|
|
197
|
+
const parsed = parseMcpDocument(args.plan, { surface: 'refs' })
|
|
198
|
+
if (parsed.entries.length !== 1) return failure('validation-failed', 'invalid-config', 'mcp_install accepts exactly one server plan.')
|
|
199
|
+
const normalized = parsed.entries[0]
|
|
200
|
+
const identity = normalized.entry.identity
|
|
201
|
+
const request = { action: 'preview', mode: 'add', identity, config: args.plan, refsOnly: true, surface: 'refs' }
|
|
202
|
+
const preview = await operations.preview(request)
|
|
203
|
+
if (preview.digest !== parsed.digest) throw Object.assign(new Error('MCP preview normalization was not stable'), { code: 'plan-changed' })
|
|
204
|
+
if (args.action === 'preview') return sanitizeMcpPayload({ ...preview, next: { action: 'review-plan-then-install' } })
|
|
205
|
+
if (previewConflict(preview)) {
|
|
206
|
+
return failure('conflict', 'name-conflict', `MCP identity or namespace is already registered: ${previewConflict(preview)}`, { identity, registryRevision: preview.registryRevision })
|
|
207
|
+
}
|
|
208
|
+
if (dependencies.isLiveRoot?.(agent) !== true) return confirmationFailure({ code: 'CALLER_NOT_LIVE' })
|
|
209
|
+
const questions = dependencies.userQuestions?.()
|
|
210
|
+
if (typeof questions?.ask !== 'function') return confirmationFailure({ code: 'NO_PROVIDER' })
|
|
211
|
+
if (typeof dependencies.mutate !== 'function' || typeof dependencies.captureOwnership !== 'function') {
|
|
212
|
+
return failure('unsupported', 'ownership-unavailable', 'Exact MCP mutation ownership cannot be verified; installation was not attempted.')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ownership = await dependencies.captureOwnership(session.id)
|
|
216
|
+
const execution = await dependencies.describeExecution?.(normalized.entry) ?? null
|
|
217
|
+
const stored = {
|
|
218
|
+
identity,
|
|
219
|
+
plan: structuredClone(preview.plan),
|
|
220
|
+
entry: structuredClone(normalized.entry),
|
|
221
|
+
digest: preview.digest,
|
|
222
|
+
entryFingerprint: fingerprintMcpEntry(normalized.entry),
|
|
223
|
+
registryRevision: preview.registryRevision,
|
|
224
|
+
}
|
|
225
|
+
const retained = dependencies.storePlan?.(stored) ?? stored
|
|
226
|
+
const question = questionFor(retained, execution)
|
|
227
|
+
const combined = cancellation(exec.signal, timeoutMs, setTimer, clearTimer)
|
|
228
|
+
let settlement
|
|
229
|
+
try {
|
|
230
|
+
settlement = await firstConfirmationSettlement(() => questions.ask({ agent, questions: [question], signal: combined.signal }), combined.signal)
|
|
231
|
+
} finally { combined.dispose() }
|
|
232
|
+
if (settlement.kind !== 'answer') return confirmationFailure(settlement.cause)
|
|
233
|
+
if (!mcpInstallApproved(settlement.value, question.id)) {
|
|
234
|
+
return failure('cancelled', 'confirmation-not-approved', 'The exact approval option was not selected alone; no probe or write occurred.')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (dependencies.isLiveRoot?.(agent) !== true) return confirmationFailure({ code: 'CALLER_NOT_LIVE' })
|
|
238
|
+
assertMcpInstallStoredPlan(retained)
|
|
239
|
+
const currentOwner = await dependencies.captureOwnership(session.id)
|
|
240
|
+
if (!dependencies.sameOwnership(ownership, currentOwner)) {
|
|
241
|
+
return failure('owner-changed', 'owner-changed', 'MCP mutation ownership changed while confirmation was open; no probe or write occurred.', {
|
|
242
|
+
next: { action: 'preview-again' },
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
const current = await operations.preview({
|
|
246
|
+
action: 'preview', mode: 'add', identity, config: retained.entry, refsOnly: true, surface: 'refs',
|
|
247
|
+
})
|
|
248
|
+
if (current.digest !== retained.digest || current.registryRevision !== retained.registryRevision) {
|
|
249
|
+
return failure('revision-conflict', 'plan-or-revision-changed', 'The MCP plan or registry revision changed while confirmation was open; preview again.', {
|
|
250
|
+
identity, registryRevision: current.registryRevision, next: { action: 'preview-again' },
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
if (previewConflict(current)) {
|
|
254
|
+
return failure('conflict', 'name-conflict', `MCP identity or namespace changed while confirmation was open: ${previewConflict(current)}`, {
|
|
255
|
+
identity, registryRevision: current.registryRevision, next: { action: 'preview-again' },
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
mutationStarted = true
|
|
260
|
+
const outcome = await dependencies.mutate(session.id, {
|
|
261
|
+
action: 'installFromAgent', identity, config: retained.entry, refsOnly: true, surface: 'refs', targetFingerprint: null,
|
|
262
|
+
})
|
|
263
|
+
return sanitizeMcpPayload(outcome)
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
const code = cause?.code ?? (mutationStarted ? 'indeterminate' : 'validation-failed')
|
|
266
|
+
if (code === 'CALLER_NOT_LIVE' || code === 'NO_PROVIDER' || code === 'DELEGATED_CALLER') return confirmationFailure(cause)
|
|
267
|
+
if (code === 'owner-changed') return failure('owner-changed', code, 'MCP mutation ownership changed; inspect actual state before retrying.', { next: { action: 'inspect-actual-state' } })
|
|
268
|
+
if (code === 'plan-changed') return failure('conflict', code, 'The approved MCP plan changed; no probe or write occurred.', { next: { action: 'preview-again' } })
|
|
269
|
+
return failure(mutationStarted ? 'indeterminate' : code === 'invalid-config' || code === 'executable-not-found' ? 'validation-failed' : 'indeterminate', code,
|
|
270
|
+
mutationStarted ? 'The confirmed MCP installation outcome is unavailable; inspect actual registry and mount state.' : 'MCP installation could not be prepared.', {
|
|
271
|
+
...(mutationStarted ? { next: { action: 'reread-registry' } } : {}),
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|