@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.
@@ -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
+ }