@auggieteo/dsh-mcp-adapter 0.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/CONTEXT.md +37 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/cordis.patch.yml +8 -0
- package/docs/adr/0001-proxy-first-tool-surface.md +7 -0
- package/docs/adr/0002-config-in-dsh-settings-namespace.md +7 -0
- package/docs/verification/v1-e2e.md +45 -0
- package/lib/client.js +1200 -0
- package/lib/client.js.map +7 -0
- package/package.json +69 -0
- package/src/host/index.js +32 -0
- package/src/host/manager.js +516 -0
- package/src/host/mcp-connection.js +157 -0
- package/src/host/output-guard.js +188 -0
- package/src/host/promotions.js +291 -0
- package/src/host/proxy-tool.js +500 -0
- package/src/host/settings.js +164 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export const DEFAULT_OUTPUT_MAX_BYTES = 50 * 1024
|
|
2
|
+
export const DEFAULT_OUTPUT_MAX_LINES = 2_000
|
|
3
|
+
export const DEFAULT_DETAILS_MAX_BYTES = 16 * 1024
|
|
4
|
+
|
|
5
|
+
function byteLength(text) {
|
|
6
|
+
return Buffer.byteLength(text, 'utf8')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function lineCount(text) {
|
|
10
|
+
return text === '' ? 0 : text.split('\n').length
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function truncateUtf8(text, maxBytes) {
|
|
14
|
+
if (byteLength(text) <= maxBytes) return text
|
|
15
|
+
let bytes = 0
|
|
16
|
+
let kept = ''
|
|
17
|
+
for (const character of text) {
|
|
18
|
+
const next = byteLength(character)
|
|
19
|
+
if (bytes + next > maxBytes) break
|
|
20
|
+
kept += character
|
|
21
|
+
bytes += next
|
|
22
|
+
}
|
|
23
|
+
return kept
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function truncateHead(text, maxBytes, maxLines) {
|
|
27
|
+
const byLines = text.split('\n').slice(0, maxLines).join('\n')
|
|
28
|
+
return truncateUtf8(byLines, maxBytes)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeStringify(value) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(value) ?? String(value)
|
|
34
|
+
} catch {
|
|
35
|
+
return String(value)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function summarizeValue(value) {
|
|
40
|
+
if (value === null) return { type: 'null' }
|
|
41
|
+
if (Array.isArray(value)) return { type: 'array', itemCount: value.length }
|
|
42
|
+
if (typeof value !== 'object') return { type: typeof value }
|
|
43
|
+
const keys = Object.keys(value)
|
|
44
|
+
return {
|
|
45
|
+
type: 'object',
|
|
46
|
+
keyCount: keys.length,
|
|
47
|
+
keys: keys.slice(0, 20),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function trySave(ctx, exec, label, suggestedName, content) {
|
|
52
|
+
const sessionId = exec.agent?.session?.header?.id
|
|
53
|
+
const spillStore = ctx.get('spillStore')
|
|
54
|
+
if (sessionId === undefined || spillStore === undefined) return undefined
|
|
55
|
+
try {
|
|
56
|
+
return await spillStore.saveText({
|
|
57
|
+
owner: { sessionId },
|
|
58
|
+
source: {
|
|
59
|
+
toolName: exec.name,
|
|
60
|
+
callId: exec.callId,
|
|
61
|
+
label,
|
|
62
|
+
},
|
|
63
|
+
suggestedName,
|
|
64
|
+
content,
|
|
65
|
+
})
|
|
66
|
+
} catch (error) {
|
|
67
|
+
ctx.logger?.warn?.(`dsh-mcp-adapter: could not save ${label}: ${String(error)}`)
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function spillReference(ref) {
|
|
73
|
+
if (ref === undefined) return undefined
|
|
74
|
+
return {
|
|
75
|
+
locator: ref.locator,
|
|
76
|
+
bytes: ref.bytes,
|
|
77
|
+
retrievalHint: ref.retrievalHint,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function truncationNotice(stats, ref) {
|
|
82
|
+
const prefix = `[MCP output truncated: original ${stats.lines} lines / ${stats.bytes} bytes.`
|
|
83
|
+
if (ref === undefined) return `${prefix} Full output could not be saved.]`
|
|
84
|
+
return `${prefix} Full text stored at: ${ref.locator}. ${ref.retrievalHint}]`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function guardText(ctx, exec, serverName, toolName, text, limits) {
|
|
88
|
+
const stats = { bytes: byteLength(text), lines: lineCount(text) }
|
|
89
|
+
if (stats.bytes <= limits.maxBytes && stats.lines <= limits.maxLines) {
|
|
90
|
+
return { text }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ref = await trySave(
|
|
94
|
+
ctx,
|
|
95
|
+
exec,
|
|
96
|
+
'mcp-output',
|
|
97
|
+
`${serverName}__${toolName}.txt`,
|
|
98
|
+
text,
|
|
99
|
+
)
|
|
100
|
+
const notice = truncationNotice(stats, ref)
|
|
101
|
+
const noticeText = `\n\n${notice}`
|
|
102
|
+
const preview = truncateHead(
|
|
103
|
+
text,
|
|
104
|
+
Math.max(0, limits.maxBytes - byteLength(noticeText)),
|
|
105
|
+
Math.max(0, limits.maxLines - lineCount(noticeText)),
|
|
106
|
+
)
|
|
107
|
+
const guardedText = truncateUtf8(
|
|
108
|
+
preview === '' ? notice : `${preview}${noticeText}`,
|
|
109
|
+
limits.maxBytes,
|
|
110
|
+
)
|
|
111
|
+
return {
|
|
112
|
+
text: guardedText,
|
|
113
|
+
guard: {
|
|
114
|
+
textTruncated: true,
|
|
115
|
+
originalBytes: stats.bytes,
|
|
116
|
+
originalLines: stats.lines,
|
|
117
|
+
returnedBytes: byteLength(guardedText),
|
|
118
|
+
returnedLines: lineCount(guardedText),
|
|
119
|
+
...(ref === undefined ? {} : { fullText: spillReference(ref) }),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function guardDetails(ctx, exec, serverName, toolName, result, limits) {
|
|
125
|
+
const raw = safeStringify(result)
|
|
126
|
+
const bytes = byteLength(raw)
|
|
127
|
+
if (bytes <= limits.detailsMaxBytes) return { data: result }
|
|
128
|
+
|
|
129
|
+
const ref = await trySave(
|
|
130
|
+
ctx,
|
|
131
|
+
exec,
|
|
132
|
+
'mcp-result',
|
|
133
|
+
`${serverName}__${toolName}-result.json`,
|
|
134
|
+
raw,
|
|
135
|
+
)
|
|
136
|
+
const content = Array.isArray(result?.content) ? result.content : []
|
|
137
|
+
const data = {
|
|
138
|
+
omitted: true,
|
|
139
|
+
reason: 'Raw MCP result exceeded the details size limit.',
|
|
140
|
+
rawResultBytes: bytes,
|
|
141
|
+
isError: result?.isError === true,
|
|
142
|
+
contentBlocks: content.length,
|
|
143
|
+
...(result?.structuredContent === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: { structuredContent: summarizeValue(result.structuredContent) }),
|
|
146
|
+
...(ref === undefined ? {} : { fullResult: spillReference(ref) }),
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
data,
|
|
150
|
+
guard: {
|
|
151
|
+
resultSummarized: true,
|
|
152
|
+
rawResultBytes: bytes,
|
|
153
|
+
...(ref === undefined ? {} : { fullResult: spillReference(ref) }),
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function guardMcpOutput(
|
|
159
|
+
ctx,
|
|
160
|
+
exec,
|
|
161
|
+
serverName,
|
|
162
|
+
toolName,
|
|
163
|
+
result,
|
|
164
|
+
text,
|
|
165
|
+
{
|
|
166
|
+
maxBytes = DEFAULT_OUTPUT_MAX_BYTES,
|
|
167
|
+
maxLines = DEFAULT_OUTPUT_MAX_LINES,
|
|
168
|
+
detailsMaxBytes = DEFAULT_DETAILS_MAX_BYTES,
|
|
169
|
+
} = {},
|
|
170
|
+
) {
|
|
171
|
+
const limits = { maxBytes, maxLines, detailsMaxBytes }
|
|
172
|
+
const [guardedText, guardedDetails] = await Promise.all([
|
|
173
|
+
guardText(ctx, exec, serverName, toolName, text, limits),
|
|
174
|
+
guardDetails(ctx, exec, serverName, toolName, result, limits),
|
|
175
|
+
])
|
|
176
|
+
return {
|
|
177
|
+
text: guardedText.text,
|
|
178
|
+
data: guardedDetails.data,
|
|
179
|
+
...(guardedText.guard === undefined && guardedDetails.guard === undefined
|
|
180
|
+
? {}
|
|
181
|
+
: {
|
|
182
|
+
guard: {
|
|
183
|
+
...guardedText.guard,
|
|
184
|
+
...guardedDetails.guard,
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMcpCallOutput,
|
|
3
|
+
executeMcpToolCall,
|
|
4
|
+
} from './proxy-tool.js'
|
|
5
|
+
|
|
6
|
+
const PUBLIC_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/
|
|
7
|
+
|
|
8
|
+
function identity(serverName, toolName) {
|
|
9
|
+
return `${serverName}\0${toolName}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function promotedToolName(serverName, toolName) {
|
|
13
|
+
return `${serverName}__${toolName}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function metadataFingerprint(serverName, tool) {
|
|
17
|
+
return JSON.stringify({
|
|
18
|
+
name: promotedToolName(serverName, tool.name),
|
|
19
|
+
description: tool.description ?? '',
|
|
20
|
+
inputSchema: tool.inputSchema,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function catalogFingerprint(manager) {
|
|
25
|
+
return JSON.stringify(
|
|
26
|
+
manager.catalogSnapshot().servers.map((server) => ({
|
|
27
|
+
name: server.name,
|
|
28
|
+
disabled: server.disabled,
|
|
29
|
+
tools: server.tools,
|
|
30
|
+
})),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createPromotedTool(ctx, manager, serverName, tool) {
|
|
39
|
+
const publicName = promotedToolName(serverName, tool.name)
|
|
40
|
+
return {
|
|
41
|
+
name: publicName,
|
|
42
|
+
description:
|
|
43
|
+
tool.description ??
|
|
44
|
+
`MCP tool ${JSON.stringify(tool.name)} from Server ${JSON.stringify(serverName)}.`,
|
|
45
|
+
parameters: tool.inputSchema,
|
|
46
|
+
output: createMcpCallOutput(),
|
|
47
|
+
execute(args, exec) {
|
|
48
|
+
if (!isRecord(args)) {
|
|
49
|
+
throw new Error(`Promoted MCP tool ${JSON.stringify(publicName)} requires object arguments`)
|
|
50
|
+
}
|
|
51
|
+
return executeMcpToolCall(
|
|
52
|
+
ctx,
|
|
53
|
+
manager,
|
|
54
|
+
serverName,
|
|
55
|
+
tool.name,
|
|
56
|
+
args,
|
|
57
|
+
exec,
|
|
58
|
+
{ approvalToolName: publicName },
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class McpPromotionRegistry {
|
|
65
|
+
constructor(ctx, manager, settingsScope) {
|
|
66
|
+
this.ctx = ctx
|
|
67
|
+
this.manager = manager
|
|
68
|
+
this.settingsScope = settingsScope
|
|
69
|
+
this.live = new Map()
|
|
70
|
+
this.failures = new Map()
|
|
71
|
+
this.revision = 0
|
|
72
|
+
this.completedRevision = 0
|
|
73
|
+
this.running = undefined
|
|
74
|
+
this.disposed = false
|
|
75
|
+
this.lastCatalog = catalogFingerprint(manager)
|
|
76
|
+
|
|
77
|
+
this.unwatchSettings = settingsScope.watch(() => this.requestSync())
|
|
78
|
+
this.unsubscribeManager = manager.subscribe(() => {
|
|
79
|
+
const nextCatalog = catalogFingerprint(manager)
|
|
80
|
+
if (nextCatalog === this.lastCatalog) return
|
|
81
|
+
this.lastCatalog = nextCatalog
|
|
82
|
+
this.requestSync()
|
|
83
|
+
})
|
|
84
|
+
this.requestSync()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
desiredServers() {
|
|
88
|
+
const servers = this.settingsScope.get().mcpServers
|
|
89
|
+
return Object.entries(servers)
|
|
90
|
+
.filter(([, config]) => !config.disabled && config.promotedTools.length > 0)
|
|
91
|
+
.map(([serverName, config]) => ({
|
|
92
|
+
serverName,
|
|
93
|
+
toolNames: [...config.promotedTools].sort(),
|
|
94
|
+
}))
|
|
95
|
+
.sort((left, right) => left.serverName.localeCompare(right.serverName))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
desiredIdentities() {
|
|
99
|
+
return new Set(
|
|
100
|
+
this.desiredServers().flatMap(({ serverName, toolNames }) =>
|
|
101
|
+
toolNames.map((toolName) => identity(serverName, toolName)),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isDesired(serverName, toolName) {
|
|
107
|
+
const config = this.settingsScope.get().mcpServers[serverName]
|
|
108
|
+
return (
|
|
109
|
+
config !== undefined &&
|
|
110
|
+
!config.disabled &&
|
|
111
|
+
config.promotedTools.includes(toolName)
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
removeEntry(key) {
|
|
116
|
+
const entry = this.live.get(key)
|
|
117
|
+
if (entry === undefined) return
|
|
118
|
+
this.live.delete(key)
|
|
119
|
+
try {
|
|
120
|
+
entry.dispose()
|
|
121
|
+
} catch {
|
|
122
|
+
// Tool disposal is idempotent from the registry's point of view.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pruneUndesired() {
|
|
127
|
+
const desired = this.desiredIdentities()
|
|
128
|
+
for (const key of this.live.keys()) {
|
|
129
|
+
if (!desired.has(key)) this.removeEntry(key)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
reportFailure(key, message) {
|
|
134
|
+
if (this.failures.get(key) === message) return
|
|
135
|
+
this.failures.set(key, message)
|
|
136
|
+
this.ctx.logger?.warn?.(`dsh-mcp-adapter: ${message}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clearFailure(key) {
|
|
140
|
+
this.failures.delete(key)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
requestSync() {
|
|
144
|
+
if (this.disposed) return
|
|
145
|
+
this.revision += 1
|
|
146
|
+
this.pruneUndesired()
|
|
147
|
+
this.startRun()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
startRun() {
|
|
151
|
+
if (this.disposed || this.running !== undefined) return
|
|
152
|
+
const run = this.runLoop().finally(() => {
|
|
153
|
+
if (this.running === run) this.running = undefined
|
|
154
|
+
if (!this.disposed && this.completedRevision !== this.revision) this.startRun()
|
|
155
|
+
})
|
|
156
|
+
this.running = run
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async runLoop() {
|
|
160
|
+
while (!this.disposed && this.completedRevision !== this.revision) {
|
|
161
|
+
const revision = this.revision
|
|
162
|
+
try {
|
|
163
|
+
await this.syncRevision(revision)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
this.ctx.logger?.error?.(
|
|
166
|
+
`dsh-mcp-adapter: Promotion reconciliation failed: ${String(error)}`,
|
|
167
|
+
)
|
|
168
|
+
this.completedRevision = revision
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
this.completedRevision = revision
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async syncRevision(revision) {
|
|
176
|
+
for (const desired of this.desiredServers()) {
|
|
177
|
+
if (this.disposed || revision !== this.revision) return
|
|
178
|
+
await this.syncServer(desired.serverName, desired.toolNames, revision)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async syncServer(serverName, toolNames, revision) {
|
|
183
|
+
const hasNewPromotion = toolNames.some(
|
|
184
|
+
(toolName) => !this.live.has(identity(serverName, toolName)),
|
|
185
|
+
)
|
|
186
|
+
const cachedTools = this.manager.getCachedTools(serverName)
|
|
187
|
+
let tools
|
|
188
|
+
try {
|
|
189
|
+
tools = hasNewPromotion
|
|
190
|
+
? await this.manager.listTools(serverName, {
|
|
191
|
+
refresh: cachedTools.length > 0,
|
|
192
|
+
})
|
|
193
|
+
: cachedTools
|
|
194
|
+
} catch (error) {
|
|
195
|
+
for (const toolName of toolNames) {
|
|
196
|
+
this.reportFailure(
|
|
197
|
+
identity(serverName, toolName),
|
|
198
|
+
`could not load Promotion ${JSON.stringify(promotedToolName(serverName, toolName))}: ${String(error)}`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (this.disposed || revision !== this.revision) return
|
|
205
|
+
for (const toolName of toolNames) {
|
|
206
|
+
if (!this.isDesired(serverName, toolName)) continue
|
|
207
|
+
const key = identity(serverName, toolName)
|
|
208
|
+
const tool = tools.find((candidate) => candidate.name === toolName)
|
|
209
|
+
if (tool === undefined) {
|
|
210
|
+
this.removeEntry(key)
|
|
211
|
+
this.reportFailure(
|
|
212
|
+
key,
|
|
213
|
+
`Promotion ${JSON.stringify(promotedToolName(serverName, toolName))} does not match a tool exposed by its Server`,
|
|
214
|
+
)
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
this.register(serverName, tool)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
register(serverName, tool) {
|
|
222
|
+
const key = identity(serverName, tool.name)
|
|
223
|
+
const publicName = promotedToolName(serverName, tool.name)
|
|
224
|
+
if (!PUBLIC_TOOL_NAME.test(publicName)) {
|
|
225
|
+
this.removeEntry(key)
|
|
226
|
+
this.reportFailure(
|
|
227
|
+
key,
|
|
228
|
+
`Promotion name ${JSON.stringify(publicName)} must match ${PUBLIC_TOOL_NAME} for DSH native tools`,
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fingerprint = metadataFingerprint(serverName, tool)
|
|
234
|
+
const current = this.live.get(key)
|
|
235
|
+
if (current?.fingerprint === fingerprint) {
|
|
236
|
+
this.clearFailure(key)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const collision = [...this.live.entries()].find(
|
|
241
|
+
([otherKey, entry]) => otherKey !== key && entry.publicName === publicName,
|
|
242
|
+
)
|
|
243
|
+
if (collision !== undefined) {
|
|
244
|
+
this.removeEntry(key)
|
|
245
|
+
this.reportFailure(
|
|
246
|
+
key,
|
|
247
|
+
`Promotion name ${JSON.stringify(publicName)} collides with another promoted MCP tool`,
|
|
248
|
+
)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.removeEntry(key)
|
|
253
|
+
try {
|
|
254
|
+
const dispose = this.ctx.tools.register(
|
|
255
|
+
createPromotedTool(this.ctx, this.manager, serverName, tool),
|
|
256
|
+
)
|
|
257
|
+
this.live.set(key, { dispose, fingerprint, publicName })
|
|
258
|
+
this.clearFailure(key)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.reportFailure(
|
|
261
|
+
key,
|
|
262
|
+
`could not register Promotion ${JSON.stringify(publicName)}: ${String(error)}`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async settled() {
|
|
268
|
+
while (this.running !== undefined) {
|
|
269
|
+
const running = this.running
|
|
270
|
+
await running
|
|
271
|
+
if (this.running === running) return
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async dispose() {
|
|
276
|
+
if (this.disposed) return
|
|
277
|
+
this.disposed = true
|
|
278
|
+
this.revision += 1
|
|
279
|
+
this.unwatchSettings?.()
|
|
280
|
+
this.unsubscribeManager?.()
|
|
281
|
+
for (const key of [...this.live.keys()]) this.removeEntry(key)
|
|
282
|
+
await this.running
|
|
283
|
+
this.failures.clear()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function installMcpPromotions(ctx, manager, settingsScope) {
|
|
288
|
+
const registry = new McpPromotionRegistry(ctx, manager, settingsScope)
|
|
289
|
+
ctx.effect(() => () => registry.dispose(), 'dsh-mcp-adapter: Promotion lifecycle')
|
|
290
|
+
return registry
|
|
291
|
+
}
|