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