weir-harness 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +40 -9
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +575 -146
- package/src/capabilities/mcp-mount.js +220 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +170 -57
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- package/src/worktree/rules.js +1 -1
|
@@ -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
|
/**
|
|
@@ -30,10 +30,11 @@ const message = error => (error instanceof Error ? error.message : String(error)
|
|
|
30
30
|
* admit: (agent: unknown) => Promise<boolean>,
|
|
31
31
|
* isEnabledFor: (agent: unknown) => boolean,
|
|
32
32
|
* drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
|
|
33
|
+
* onRegister?: (name: string, captureRollback: (rollback: () => void) => void) => void, onUnregister?: (name: string) => void,
|
|
33
34
|
* onError?: (reason: string, cause?: unknown) => void }} config
|
|
34
35
|
*/
|
|
35
|
-
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {} }) {
|
|
36
|
-
const wrapper = createMcpToolWrapper({ identity, generation, admit, drain })
|
|
36
|
+
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onUnregister, onError = () => {}, assertPermit }) {
|
|
37
|
+
const wrapper = createMcpToolWrapper({ identity, generation, admit, drain, assertPermit })
|
|
37
38
|
|
|
38
39
|
const unknown = (service, method) => {
|
|
39
40
|
const reason = `facade-unknown-method:${service}.${method}`
|
|
@@ -43,14 +44,44 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
43
44
|
|
|
44
45
|
const tools = {
|
|
45
46
|
register(definition) {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
47
|
+
// Host registration happens first, then the generation-owned fact and
|
|
48
|
+
// every live agent converge in this same synchronous segment.
|
|
48
49
|
const wrapped = wrapper.wrap(definition)
|
|
49
50
|
const registration = /** @type {{ register(d: unknown): unknown }} */ (host.tools).register(wrapped)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
if (typeof wrapped?.name !== 'string') return registration
|
|
52
|
+
const name = wrapped.name
|
|
53
|
+
const disposeHost = typeof registration === 'function' ? registration : () => {}
|
|
54
|
+
let rollbackRegistration = null
|
|
55
|
+
try {
|
|
56
|
+
onRegister?.(name, rollback => { rollbackRegistration = rollback })
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
// Host-first rollback is fail-closed: owner facts and partial denies
|
|
59
|
+
// stay in place unless Host removal is confirmed.
|
|
60
|
+
try { disposeHost() } catch (rollbackError) {
|
|
61
|
+
onError('facade-host-registration-rollback-failed', rollbackError)
|
|
62
|
+
throw cause
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
if (typeof rollbackRegistration === 'function') rollbackRegistration()
|
|
66
|
+
else onUnregister?.(name) // legacy registration callbacks
|
|
67
|
+
} catch (rollbackError) {
|
|
68
|
+
onError('facade-owner-registration-rollback-failed', rollbackError)
|
|
69
|
+
}
|
|
70
|
+
throw cause
|
|
71
|
+
}
|
|
72
|
+
let hostRemoved = false
|
|
73
|
+
let completed = false
|
|
74
|
+
return () => {
|
|
75
|
+
if (completed) return
|
|
76
|
+
if (!hostRemoved) {
|
|
77
|
+
// Host-first is fail-closed: if removal throws, the manager fact and
|
|
78
|
+
// deny stay in place. A later retry does not double-dispose the Host.
|
|
79
|
+
disposeHost()
|
|
80
|
+
hostRemoved = true
|
|
81
|
+
}
|
|
82
|
+
onUnregister?.(name)
|
|
83
|
+
completed = true
|
|
84
|
+
}
|
|
54
85
|
},
|
|
55
86
|
get(...args) { return /** @type {{ get(...a: unknown[]): unknown }} */ (host.tools).get(...args) },
|
|
56
87
|
list(...args) { return /** @type {{ list(...a: unknown[]): unknown }} */ (host.tools).list(...args) },
|
|
@@ -91,7 +122,7 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
91
122
|
const gated = typeof request === 'function'
|
|
92
123
|
? { ...provider, async request(args, exec) {
|
|
93
124
|
const admitted = await admit(exec?.agent)
|
|
94
|
-
if (!admitted) {
|
|
125
|
+
if (!admitted || (assertPermit && !assertPermit(admitted))) {
|
|
95
126
|
const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
|
|
96
127
|
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
97
128
|
}
|
|
@@ -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
|
}
|