@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,500 @@
|
|
|
1
|
+
import { guardMcpOutput } from './output-guard.js'
|
|
2
|
+
|
|
3
|
+
const MAX_SEARCH_RESULTS = 20
|
|
4
|
+
const APPROVAL_PREVIEW_CHARS = 600
|
|
5
|
+
const SENSITIVE_KEY = /token|secret|password|authorization|api[_-]?key|cookie/i
|
|
6
|
+
const PROXY_FIELDS = new Set(['action', 'query', 'server', 'tool', 'args'])
|
|
7
|
+
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function errorMessage(error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validateProxyArgs(args) {
|
|
17
|
+
const unknown = Object.keys(args).filter((key) => !PROXY_FIELDS.has(key))
|
|
18
|
+
if (unknown.length > 0) {
|
|
19
|
+
throw new Error(`Unknown mcp argument field ${JSON.stringify(unknown[0])}`)
|
|
20
|
+
}
|
|
21
|
+
if (args.query !== undefined && typeof args.query !== 'string') {
|
|
22
|
+
throw new Error('mcp query must be a string when supplied')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requireText(value, field, action) {
|
|
27
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
28
|
+
throw new Error(`mcp action ${JSON.stringify(action)} requires a non-empty ${field}`)
|
|
29
|
+
}
|
|
30
|
+
return value.trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function prefixedName(server, tool) {
|
|
34
|
+
return `${server}__${tool}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function editDistance(left, right) {
|
|
38
|
+
const previous = Array.from({ length: right.length + 1 }, (_, index) => index)
|
|
39
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
40
|
+
const current = [leftIndex]
|
|
41
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
|
42
|
+
current[rightIndex] = Math.min(
|
|
43
|
+
current[rightIndex - 1] + 1,
|
|
44
|
+
previous[rightIndex] + 1,
|
|
45
|
+
previous[rightIndex - 1] +
|
|
46
|
+
(left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
previous.splice(0, previous.length, ...current)
|
|
50
|
+
}
|
|
51
|
+
return previous[right.length]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function suggestions(values, requested, limit = 3) {
|
|
55
|
+
const query = requested.toLowerCase()
|
|
56
|
+
return [...new Set(values)]
|
|
57
|
+
.map((value) => {
|
|
58
|
+
const candidate = value.toLowerCase()
|
|
59
|
+
const contains = candidate.includes(query) || query.includes(candidate)
|
|
60
|
+
return {
|
|
61
|
+
value,
|
|
62
|
+
score: (contains ? -100 : 0) + editDistance(candidate, query),
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.sort((left, right) => left.score - right.score || left.value.localeCompare(right.value))
|
|
66
|
+
.slice(0, limit)
|
|
67
|
+
.map((entry) => entry.value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function suggestionText(values) {
|
|
71
|
+
return values.length === 0 ? '' : ` Did you mean: ${values.join(', ')}?`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function searchScore(tool, query) {
|
|
75
|
+
if (query === '') return 1
|
|
76
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
|
77
|
+
const rawName = tool.tool.toLowerCase()
|
|
78
|
+
const fullName = tool.name.toLowerCase()
|
|
79
|
+
const description = tool.description.toLowerCase()
|
|
80
|
+
let score = 0
|
|
81
|
+
for (const term of terms) {
|
|
82
|
+
if (rawName === term || fullName === term) score += 1_000
|
|
83
|
+
else if (rawName.startsWith(term) || fullName.startsWith(term)) score += 700
|
|
84
|
+
else if (rawName.includes(term) || fullName.includes(term)) score += 500
|
|
85
|
+
else if (description.includes(term)) score += 200
|
|
86
|
+
else return 0
|
|
87
|
+
}
|
|
88
|
+
return score
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function redactPreview(value, depth = 0) {
|
|
92
|
+
if (depth > 4) return '[nested value omitted]'
|
|
93
|
+
if (Array.isArray(value)) return value.slice(0, 20).map((item) => redactPreview(item, depth + 1))
|
|
94
|
+
if (!isRecord(value)) return value
|
|
95
|
+
const output = {}
|
|
96
|
+
for (const [key, item] of Object.entries(value).slice(0, 30)) {
|
|
97
|
+
output[key] = SENSITIVE_KEY.test(key) ? '[redacted]' : redactPreview(item, depth + 1)
|
|
98
|
+
}
|
|
99
|
+
return output
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function argsPreview(args) {
|
|
103
|
+
let preview
|
|
104
|
+
try {
|
|
105
|
+
preview = JSON.stringify(redactPreview(args))
|
|
106
|
+
} catch {
|
|
107
|
+
preview = '[arguments could not be rendered]'
|
|
108
|
+
}
|
|
109
|
+
if (preview.length <= APPROVAL_PREVIEW_CHARS) return preview
|
|
110
|
+
return `${preview.slice(0, APPROVAL_PREVIEW_CHARS - 1)}…`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderResource(resource) {
|
|
114
|
+
if (typeof resource?.text === 'string') {
|
|
115
|
+
return `[resource ${resource.uri ?? 'unknown'}]\n${resource.text}`
|
|
116
|
+
}
|
|
117
|
+
return `[resource ${resource?.uri ?? 'unknown'}: binary content omitted]`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderMcpResult(result, toolName) {
|
|
121
|
+
if (!Array.isArray(result?.content)) {
|
|
122
|
+
if (isRecord(result) && 'toolResult' in result) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.stringify(result.toolResult)
|
|
125
|
+
} catch {
|
|
126
|
+
return String(result.toolResult)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (result?.structuredContent !== undefined) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.stringify(result.structuredContent)
|
|
132
|
+
} catch {
|
|
133
|
+
return String(result.structuredContent)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return '(empty result)'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const parts = result.content.map((block) => {
|
|
140
|
+
if (!isRecord(block)) return '[unsupported MCP content block]'
|
|
141
|
+
if (block.type === 'text') return typeof block.text === 'string' ? block.text : ''
|
|
142
|
+
if (block.type === 'resource') return renderResource(block.resource)
|
|
143
|
+
if (block.type === 'resource_link') return `[resource link: ${block.uri ?? 'unknown'}]`
|
|
144
|
+
if (block.type === 'image') return `[image from ${toolName}: ${block.mimeType ?? 'unknown media type'}]`
|
|
145
|
+
if (block.type === 'audio') return `[audio from ${toolName}: ${block.mimeType ?? 'unknown media type'}]`
|
|
146
|
+
return `[unsupported MCP content type: ${String(block.type)}]`
|
|
147
|
+
})
|
|
148
|
+
const text = parts.filter((part) => part !== '').join('\n')
|
|
149
|
+
return text === '' ? '(empty result)' : text
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function setupHint(manager, serverName, error) {
|
|
153
|
+
const config = manager.getServerConfig(serverName)
|
|
154
|
+
const base = `MCP Server ${JSON.stringify(serverName)} failed: ${errorMessage(error)}`
|
|
155
|
+
if (config?.disabled) {
|
|
156
|
+
return `${base}\nSetup hint: enable this Server in Settings > MCP.`
|
|
157
|
+
}
|
|
158
|
+
if (typeof config?.command === 'string') {
|
|
159
|
+
return `${base}\nSetup hint: check command ${JSON.stringify(config.command)}, its arguments, executable path, and Server stderr in Settings > MCP.`
|
|
160
|
+
}
|
|
161
|
+
if (typeof config?.url === 'string') {
|
|
162
|
+
return `${base}\nSetup hint: check the URL, Server availability, and header or bearer credentials in Settings > MCP.`
|
|
163
|
+
}
|
|
164
|
+
return `${base}\nSetup hint: check this Server's Config in Settings > MCP.`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function listServerTools(manager, serverName, signal, refresh = false) {
|
|
168
|
+
const config = manager.getServerConfig(serverName)
|
|
169
|
+
if (config === undefined) {
|
|
170
|
+
const names = manager.catalogSnapshot().servers.map((server) => server.name)
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Unknown MCP Server ${JSON.stringify(serverName)}.` +
|
|
173
|
+
suggestionText(suggestions(names, serverName)),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return await manager.listTools(serverName, { signal, refresh })
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new Error(setupHint(manager, serverName, error), { cause: error })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function findTool(tools, serverName, requested) {
|
|
184
|
+
const exact = tools.find((tool) => tool.name === requested)
|
|
185
|
+
if (exact !== undefined) return exact
|
|
186
|
+
const prefix = `${serverName}__`
|
|
187
|
+
if (requested.startsWith(prefix)) {
|
|
188
|
+
return tools.find((tool) => tool.name === requested.slice(prefix.length))
|
|
189
|
+
}
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function resolveMcpTool(manager, serverName, requested, signal) {
|
|
194
|
+
let tools = await listServerTools(manager, serverName, signal)
|
|
195
|
+
let tool = findTool(tools, serverName, requested)
|
|
196
|
+
if (tool !== undefined) return tool
|
|
197
|
+
|
|
198
|
+
tools = await listServerTools(manager, serverName, signal, true)
|
|
199
|
+
tool = findTool(tools, serverName, requested)
|
|
200
|
+
if (tool !== undefined) return tool
|
|
201
|
+
|
|
202
|
+
const candidates = tools.flatMap((candidate) => [
|
|
203
|
+
candidate.name,
|
|
204
|
+
prefixedName(serverName, candidate.name),
|
|
205
|
+
])
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Unknown tool ${JSON.stringify(requested)} on MCP Server ${JSON.stringify(serverName)}.` +
|
|
208
|
+
suggestionText(suggestions(candidates, requested)),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function warmCatalog(manager, signal) {
|
|
213
|
+
const errors = []
|
|
214
|
+
const current = manager.catalogSnapshot()
|
|
215
|
+
await Promise.all(
|
|
216
|
+
current.servers
|
|
217
|
+
.filter((server) => !server.disabled && server.tools.length === 0)
|
|
218
|
+
.map(async (server) => {
|
|
219
|
+
try {
|
|
220
|
+
await manager.listTools(server.name, { signal })
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (signal?.aborted) throw error
|
|
223
|
+
errors.push({ server: server.name, message: setupHint(manager, server.name, error) })
|
|
224
|
+
}
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
errors.sort((left, right) => left.server.localeCompare(right.server))
|
|
228
|
+
return { catalog: manager.catalogSnapshot(), errors }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function searchAction(ctx, manager, query, exec) {
|
|
232
|
+
const { catalog, errors } = await warmCatalog(manager, exec.signal)
|
|
233
|
+
const candidates = catalog.servers.flatMap((server) =>
|
|
234
|
+
server.disabled
|
|
235
|
+
? []
|
|
236
|
+
: server.tools.map((tool) => ({
|
|
237
|
+
name: prefixedName(server.name, tool.name),
|
|
238
|
+
server: server.name,
|
|
239
|
+
tool: tool.name,
|
|
240
|
+
description: tool.description ?? '',
|
|
241
|
+
})),
|
|
242
|
+
)
|
|
243
|
+
const results = candidates
|
|
244
|
+
.map((tool) => ({ tool, score: searchScore(tool, query) }))
|
|
245
|
+
.filter((entry) => entry.score > 0)
|
|
246
|
+
.sort(
|
|
247
|
+
(left, right) =>
|
|
248
|
+
right.score - left.score || left.tool.name.localeCompare(right.tool.name),
|
|
249
|
+
)
|
|
250
|
+
.slice(0, MAX_SEARCH_RESULTS)
|
|
251
|
+
.map((entry) => entry.tool)
|
|
252
|
+
|
|
253
|
+
const lines = results.map(
|
|
254
|
+
(tool) => `${tool.name}\n ${tool.description || '(no description)'}`,
|
|
255
|
+
)
|
|
256
|
+
if (lines.length === 0) lines.push(`No MCP tools matched ${JSON.stringify(query)}.`)
|
|
257
|
+
if (errors.length > 0) {
|
|
258
|
+
lines.push(
|
|
259
|
+
'',
|
|
260
|
+
'Unavailable Servers:',
|
|
261
|
+
...errors.map((error) => `- ${error.server}: ${error.message.split('\n')[0]}`),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
const data = { results, errors }
|
|
265
|
+
const guarded = await guardMcpOutput(
|
|
266
|
+
ctx,
|
|
267
|
+
exec,
|
|
268
|
+
'catalog',
|
|
269
|
+
'search',
|
|
270
|
+
data,
|
|
271
|
+
lines.join('\n'),
|
|
272
|
+
)
|
|
273
|
+
return {
|
|
274
|
+
action: 'search',
|
|
275
|
+
text: guarded.text,
|
|
276
|
+
data: guarded.data,
|
|
277
|
+
...(guarded.guard === undefined ? {} : { guard: guarded.guard }),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function describeAction(ctx, manager, serverName, requested, exec) {
|
|
282
|
+
const tool = await resolveMcpTool(manager, serverName, requested, exec.signal)
|
|
283
|
+
const detail = {
|
|
284
|
+
name: prefixedName(serverName, tool.name),
|
|
285
|
+
server: serverName,
|
|
286
|
+
tool: tool.name,
|
|
287
|
+
description: tool.description ?? '',
|
|
288
|
+
inputSchema: tool.inputSchema,
|
|
289
|
+
...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
|
|
290
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
291
|
+
}
|
|
292
|
+
const text = [
|
|
293
|
+
detail.name,
|
|
294
|
+
detail.description || '(no description)',
|
|
295
|
+
'',
|
|
296
|
+
'Input schema:',
|
|
297
|
+
JSON.stringify(detail.inputSchema, null, 2),
|
|
298
|
+
].join('\n')
|
|
299
|
+
const guarded = await guardMcpOutput(
|
|
300
|
+
ctx,
|
|
301
|
+
exec,
|
|
302
|
+
serverName,
|
|
303
|
+
tool.name,
|
|
304
|
+
detail,
|
|
305
|
+
text,
|
|
306
|
+
)
|
|
307
|
+
return {
|
|
308
|
+
action: 'describe',
|
|
309
|
+
text: guarded.text,
|
|
310
|
+
data: guarded.data,
|
|
311
|
+
...(guarded.guard === undefined ? {} : { guard: guarded.guard }),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function requireApproval(
|
|
316
|
+
ctx,
|
|
317
|
+
manager,
|
|
318
|
+
serverName,
|
|
319
|
+
toolName,
|
|
320
|
+
args,
|
|
321
|
+
exec,
|
|
322
|
+
approvalToolName,
|
|
323
|
+
) {
|
|
324
|
+
const config = manager.getServerConfig(serverName)
|
|
325
|
+
if (config?.autoAllow) return
|
|
326
|
+
const approval = ctx.get('approval')
|
|
327
|
+
if (approval === undefined) {
|
|
328
|
+
throw new Error('MCP call requires approval, but the DSH approval Service is unavailable')
|
|
329
|
+
}
|
|
330
|
+
if (exec.agent === undefined) {
|
|
331
|
+
throw new Error('MCP call requires approval, but this tool call has no Agent owner')
|
|
332
|
+
}
|
|
333
|
+
const outcome = await approval.request({
|
|
334
|
+
agent: exec.agent,
|
|
335
|
+
toolName: approvalToolName,
|
|
336
|
+
callId: exec.callId,
|
|
337
|
+
reason:
|
|
338
|
+
`Call MCP Server ${JSON.stringify(serverName)} tool ${JSON.stringify(toolName)}. ` +
|
|
339
|
+
`Arguments preview: ${argsPreview(args)}`,
|
|
340
|
+
signal: exec.signal,
|
|
341
|
+
})
|
|
342
|
+
if (outcome !== 'allowed-once') {
|
|
343
|
+
throw new Error(`MCP call was not approved (${outcome})`)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function executeMcpToolCall(
|
|
348
|
+
ctx,
|
|
349
|
+
manager,
|
|
350
|
+
serverName,
|
|
351
|
+
requested,
|
|
352
|
+
args,
|
|
353
|
+
exec,
|
|
354
|
+
{ approvalToolName = 'mcp' } = {},
|
|
355
|
+
) {
|
|
356
|
+
const tool = await resolveMcpTool(manager, serverName, requested, exec.signal)
|
|
357
|
+
await requireApproval(
|
|
358
|
+
ctx,
|
|
359
|
+
manager,
|
|
360
|
+
serverName,
|
|
361
|
+
tool.name,
|
|
362
|
+
args,
|
|
363
|
+
exec,
|
|
364
|
+
approvalToolName,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
let result
|
|
368
|
+
try {
|
|
369
|
+
result = await manager.callTool(serverName, tool.name, args, { signal: exec.signal })
|
|
370
|
+
} catch (error) {
|
|
371
|
+
throw new Error(setupHint(manager, serverName, error), { cause: error })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const text = renderMcpResult(result, tool.name)
|
|
375
|
+
const guarded = await guardMcpOutput(
|
|
376
|
+
ctx,
|
|
377
|
+
exec,
|
|
378
|
+
serverName,
|
|
379
|
+
tool.name,
|
|
380
|
+
result,
|
|
381
|
+
text,
|
|
382
|
+
)
|
|
383
|
+
if (result?.isError === true) {
|
|
384
|
+
throw new Error(`MCP tool ${prefixedName(serverName, tool.name)} failed: ${guarded.text}`)
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
action: 'call',
|
|
388
|
+
text: guarded.text,
|
|
389
|
+
data: guarded.data,
|
|
390
|
+
...(guarded.guard === undefined ? {} : { guard: guarded.guard }),
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function createMcpCallOutput() {
|
|
395
|
+
return {
|
|
396
|
+
schema: {
|
|
397
|
+
type: 'object',
|
|
398
|
+
properties: {
|
|
399
|
+
action: { type: 'string', const: 'call' },
|
|
400
|
+
text: { type: 'string' },
|
|
401
|
+
data: {},
|
|
402
|
+
guard: {},
|
|
403
|
+
},
|
|
404
|
+
required: ['action', 'text', 'data'],
|
|
405
|
+
additionalProperties: false,
|
|
406
|
+
},
|
|
407
|
+
render(_args, value) {
|
|
408
|
+
return [{ type: 'text', text: value.text }]
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function createMcpProxyTool(ctx, manager) {
|
|
414
|
+
return {
|
|
415
|
+
name: 'mcp',
|
|
416
|
+
description:
|
|
417
|
+
'Discover and call MCP tools without loading every tool schema. ' +
|
|
418
|
+
'Use action "search" with query first. Use "describe" with server and tool for its schema. ' +
|
|
419
|
+
'Use "call" with server, tool, and args. Search names use <server>__<tool>.',
|
|
420
|
+
parameters: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
action: {
|
|
424
|
+
type: 'string',
|
|
425
|
+
enum: ['search', 'describe', 'call'],
|
|
426
|
+
description: 'Operation to perform.',
|
|
427
|
+
},
|
|
428
|
+
query: {
|
|
429
|
+
type: 'string',
|
|
430
|
+
description: 'Search words. Used by search.',
|
|
431
|
+
},
|
|
432
|
+
server: {
|
|
433
|
+
type: 'string',
|
|
434
|
+
description: 'Configured MCP Server name. Used by describe and call.',
|
|
435
|
+
},
|
|
436
|
+
tool: {
|
|
437
|
+
type: 'string',
|
|
438
|
+
description: 'Raw tool name or <server>__<tool> search name. Used by describe and call.',
|
|
439
|
+
},
|
|
440
|
+
args: {
|
|
441
|
+
type: 'object',
|
|
442
|
+
description: 'Arguments sent to the MCP tool. Used by call.',
|
|
443
|
+
additionalProperties: true,
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
required: ['action'],
|
|
447
|
+
additionalProperties: false,
|
|
448
|
+
},
|
|
449
|
+
output: {
|
|
450
|
+
schema: {
|
|
451
|
+
type: 'object',
|
|
452
|
+
properties: {
|
|
453
|
+
action: { type: 'string', enum: ['search', 'describe', 'call'] },
|
|
454
|
+
text: { type: 'string' },
|
|
455
|
+
data: {},
|
|
456
|
+
guard: {},
|
|
457
|
+
},
|
|
458
|
+
required: ['action', 'text', 'data'],
|
|
459
|
+
additionalProperties: false,
|
|
460
|
+
},
|
|
461
|
+
render(_args, value) {
|
|
462
|
+
return [{ type: 'text', text: value.text }]
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
async execute(args, exec) {
|
|
466
|
+
if (!isRecord(args)) throw new Error('mcp arguments must be an object')
|
|
467
|
+
validateProxyArgs(args)
|
|
468
|
+
const action = requireText(args.action, 'action', 'unknown')
|
|
469
|
+
if (action === 'search') {
|
|
470
|
+
const query = typeof args.query === 'string' ? args.query.trim() : ''
|
|
471
|
+
return searchAction(ctx, manager, query, exec)
|
|
472
|
+
}
|
|
473
|
+
if (action === 'describe') {
|
|
474
|
+
const serverName = requireText(args.server, 'server', action)
|
|
475
|
+
const toolName = requireText(args.tool, 'tool', action)
|
|
476
|
+
return describeAction(ctx, manager, serverName, toolName, exec)
|
|
477
|
+
}
|
|
478
|
+
if (action === 'call') {
|
|
479
|
+
const serverName = requireText(args.server, 'server', action)
|
|
480
|
+
const toolName = requireText(args.tool, 'tool', action)
|
|
481
|
+
if (args.args !== undefined && !isRecord(args.args)) {
|
|
482
|
+
throw new Error('mcp action "call" requires args to be an object when supplied')
|
|
483
|
+
}
|
|
484
|
+
return executeMcpToolCall(
|
|
485
|
+
ctx,
|
|
486
|
+
manager,
|
|
487
|
+
serverName,
|
|
488
|
+
toolName,
|
|
489
|
+
args.args ?? {},
|
|
490
|
+
exec,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
throw new Error(`Unknown mcp action ${JSON.stringify(action)}. Use search, describe, or call.`)
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function installMcpProxyTool(ctx, manager) {
|
|
499
|
+
return ctx.tools.register(createMcpProxyTool(ctx, manager))
|
|
500
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
2
|
+
import z from '@deepseek-ai/schemastery'
|
|
3
|
+
|
|
4
|
+
export const MCP_SETTINGS_NAMESPACE = settingsNamespace('mcp')
|
|
5
|
+
|
|
6
|
+
const optionalString = () => z.string().required(false)
|
|
7
|
+
|
|
8
|
+
export const McpServerSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
// Standard mcpServers transport fields. command and url stay optional in
|
|
11
|
+
// the serializable schema; validateMcpSettings enforces exactly one.
|
|
12
|
+
command: optionalString().description('Executable for a stdio MCP server.'),
|
|
13
|
+
args: z
|
|
14
|
+
.array(z.string())
|
|
15
|
+
.description('Arguments passed to the stdio server command.'),
|
|
16
|
+
env: z
|
|
17
|
+
.dict(z.string().role('secret'))
|
|
18
|
+
.description('Environment variables passed to the stdio server.'),
|
|
19
|
+
url: optionalString().description(
|
|
20
|
+
'HTTP(S) endpoint for streamable HTTP with SSE fallback.',
|
|
21
|
+
),
|
|
22
|
+
headers: z
|
|
23
|
+
.dict(z.string().role('secret'))
|
|
24
|
+
.description('Headers sent to the remote HTTP MCP server.'),
|
|
25
|
+
|
|
26
|
+
// Adapter extensions. These fields remain paste-compatible with standard
|
|
27
|
+
// clients because other clients ignore unknown per-server keys.
|
|
28
|
+
disabled: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.default(false)
|
|
31
|
+
.description('Disable this server without deleting its Config.'),
|
|
32
|
+
autoAllow: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.default(false)
|
|
35
|
+
.description('Run this server’s tool calls without DSH approval prompts.'),
|
|
36
|
+
lifecycle: z
|
|
37
|
+
.union(['lazy'])
|
|
38
|
+
.default('lazy')
|
|
39
|
+
.description('Connection lifecycle. Version 1 supports lazy only.'),
|
|
40
|
+
idleTimeoutMinutes: z
|
|
41
|
+
.number()
|
|
42
|
+
.default(10)
|
|
43
|
+
.description('Disconnect after this many idle minutes.'),
|
|
44
|
+
promotedTools: z
|
|
45
|
+
.array(z.string())
|
|
46
|
+
.default([])
|
|
47
|
+
.description('MCP tool names promoted to native DSH tools.'),
|
|
48
|
+
})
|
|
49
|
+
.description('One configured MCP server.')
|
|
50
|
+
|
|
51
|
+
export const McpSettingsSchema = z.object({
|
|
52
|
+
mcpServers: z
|
|
53
|
+
.dict(McpServerSchema)
|
|
54
|
+
.default({})
|
|
55
|
+
.description('Global MCP servers, keyed by their unique server name.'),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const TOP_LEVEL_KEYS = new Set(['mcpServers'])
|
|
59
|
+
const SERVER_KEYS = new Set([
|
|
60
|
+
'command',
|
|
61
|
+
'args',
|
|
62
|
+
'env',
|
|
63
|
+
'url',
|
|
64
|
+
'headers',
|
|
65
|
+
'disabled',
|
|
66
|
+
'autoAllow',
|
|
67
|
+
'lifecycle',
|
|
68
|
+
'idleTimeoutMinutes',
|
|
69
|
+
'promotedTools',
|
|
70
|
+
])
|
|
71
|
+
const RESERVED_SERVER_NAMES = new Set(['__proto__', 'prototype', 'constructor'])
|
|
72
|
+
|
|
73
|
+
function fail(path, message) {
|
|
74
|
+
throw new TypeError(`${path}: ${message}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rejectUnknownKeys(value, allowed, path) {
|
|
78
|
+
for (const key of Object.keys(value)) {
|
|
79
|
+
if (!allowed.has(key)) fail(`${path}.${key}`, 'unknown field')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cross-field checks that the serializable Schemastery shape cannot express.
|
|
85
|
+
* SettingsProvider runs this after schema validation and before every persist.
|
|
86
|
+
*/
|
|
87
|
+
export function validateMcpSettings(value) {
|
|
88
|
+
rejectUnknownKeys(value, TOP_LEVEL_KEYS, 'mcp')
|
|
89
|
+
|
|
90
|
+
for (const [serverName, server] of Object.entries(value.mcpServers)) {
|
|
91
|
+
const path = `mcp.mcpServers.${serverName}`
|
|
92
|
+
|
|
93
|
+
if (serverName.trim() === '') fail('mcp.mcpServers', 'server names cannot be empty')
|
|
94
|
+
if (RESERVED_SERVER_NAMES.has(serverName)) {
|
|
95
|
+
fail(path, 'reserved server name')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
rejectUnknownKeys(server, SERVER_KEYS, path)
|
|
99
|
+
|
|
100
|
+
const hasCommand = typeof server.command === 'string'
|
|
101
|
+
const hasUrl = typeof server.url === 'string'
|
|
102
|
+
if (hasCommand === hasUrl) {
|
|
103
|
+
fail(path, 'configure exactly one transport: "command" (stdio) or "url" (HTTP)')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (hasCommand) {
|
|
107
|
+
if (server.command.trim() === '') fail(`${path}.command`, 'cannot be empty')
|
|
108
|
+
if (Object.keys(server.headers).length > 0) {
|
|
109
|
+
fail(`${path}.headers`, 'only HTTP servers may configure headers')
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
if (server.url.trim() === '') fail(`${path}.url`, 'cannot be empty')
|
|
113
|
+
let url
|
|
114
|
+
try {
|
|
115
|
+
url = new URL(server.url)
|
|
116
|
+
} catch {
|
|
117
|
+
fail(`${path}.url`, 'must be a valid absolute HTTP(S) URL')
|
|
118
|
+
}
|
|
119
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
120
|
+
fail(`${path}.url`, 'must use http: or https:')
|
|
121
|
+
}
|
|
122
|
+
if (server.args.length > 0) {
|
|
123
|
+
fail(`${path}.args`, 'only stdio servers may configure args')
|
|
124
|
+
}
|
|
125
|
+
if (Object.keys(server.env).length > 0) {
|
|
126
|
+
fail(`${path}.env`, 'only stdio servers may configure env')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
!Number.isFinite(server.idleTimeoutMinutes) ||
|
|
132
|
+
server.idleTimeoutMinutes <= 0
|
|
133
|
+
) {
|
|
134
|
+
fail(`${path}.idleTimeoutMinutes`, 'must be a positive number')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const seenTools = new Set()
|
|
138
|
+
for (const toolName of server.promotedTools) {
|
|
139
|
+
if (toolName.trim() === '') {
|
|
140
|
+
fail(`${path}.promotedTools`, 'tool names cannot be empty')
|
|
141
|
+
}
|
|
142
|
+
if (seenTools.has(toolName)) {
|
|
143
|
+
fail(`${path}.promotedTools`, `duplicate tool name ${JSON.stringify(toolName)}`)
|
|
144
|
+
}
|
|
145
|
+
seenTools.add(toolName)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Register Config while the optional Host settings service exists. The scope
|
|
152
|
+
* belongs to the injected fiber, so provider reload or Adapter disposal removes
|
|
153
|
+
* the namespace and every watcher cleanly.
|
|
154
|
+
*/
|
|
155
|
+
export function installMcpSettings(ctx, activate) {
|
|
156
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
157
|
+
const scope = settingsCtx.settings.register(
|
|
158
|
+
MCP_SETTINGS_NAMESPACE,
|
|
159
|
+
McpSettingsSchema,
|
|
160
|
+
{ validate: validateMcpSettings },
|
|
161
|
+
)
|
|
162
|
+
return activate?.(settingsCtx, scope)
|
|
163
|
+
})
|
|
164
|
+
}
|