@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,516 @@
|
|
|
1
|
+
import {
|
|
2
|
+
REQUEST_TIMEOUT_MS,
|
|
3
|
+
createMcpConnection,
|
|
4
|
+
} from './mcp-connection.js'
|
|
5
|
+
|
|
6
|
+
function errorMessage(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function abortError(signal) {
|
|
11
|
+
if (signal.reason instanceof Error) return signal.reason
|
|
12
|
+
const error = new Error('Operation aborted')
|
|
13
|
+
error.name = 'AbortError'
|
|
14
|
+
return error
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function waitWithSignal(promise, signal) {
|
|
18
|
+
if (signal === undefined) return promise
|
|
19
|
+
if (signal.aborted) return Promise.reject(abortError(signal))
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const onAbort = () => reject(abortError(signal))
|
|
22
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
23
|
+
promise.then(resolve, reject).finally(() => {
|
|
24
|
+
signal.removeEventListener('abort', onAbort)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cloneJson(value) {
|
|
30
|
+
return structuredClone(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function equalJson(left, right) {
|
|
34
|
+
if (left === right) return true
|
|
35
|
+
if (typeof left !== typeof right || left === null || right === null) return false
|
|
36
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
37
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
return left.every((item, index) => equalJson(item, right[index]))
|
|
41
|
+
}
|
|
42
|
+
if (typeof left !== 'object') return false
|
|
43
|
+
const leftKeys = Object.keys(left).sort()
|
|
44
|
+
const rightKeys = Object.keys(right).sort()
|
|
45
|
+
return (
|
|
46
|
+
leftKeys.length === rightKeys.length &&
|
|
47
|
+
leftKeys.every(
|
|
48
|
+
(key, index) => key === rightKeys[index] && equalJson(left[key], right[key]),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeTool(tool) {
|
|
54
|
+
return cloneJson({
|
|
55
|
+
name: tool.name,
|
|
56
|
+
...(tool.description === undefined ? {} : { description: tool.description }),
|
|
57
|
+
inputSchema: tool.inputSchema,
|
|
58
|
+
...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
|
|
59
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function connectionConfig(config) {
|
|
64
|
+
return {
|
|
65
|
+
command: config.command,
|
|
66
|
+
args: config.args,
|
|
67
|
+
env: config.env,
|
|
68
|
+
url: config.url,
|
|
69
|
+
headers: config.headers,
|
|
70
|
+
disabled: config.disabled,
|
|
71
|
+
lifecycle: config.lifecycle,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function initialState(config) {
|
|
76
|
+
return config.disabled ? 'disabled' : 'disconnected'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createRecord(name, config) {
|
|
80
|
+
return {
|
|
81
|
+
name,
|
|
82
|
+
config,
|
|
83
|
+
state: initialState(config),
|
|
84
|
+
message: undefined,
|
|
85
|
+
transportType: undefined,
|
|
86
|
+
tools: [],
|
|
87
|
+
connection: undefined,
|
|
88
|
+
connectPromise: undefined,
|
|
89
|
+
connectAbort: undefined,
|
|
90
|
+
generation: 0,
|
|
91
|
+
activeCalls: 0,
|
|
92
|
+
cancelIdle: undefined,
|
|
93
|
+
lastUsedAt: undefined,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Host owner for MCP connections and cached tool metadata.
|
|
99
|
+
*
|
|
100
|
+
* The manager does not expose SDK Client or Transport instances. Consumers get
|
|
101
|
+
* detached JSON snapshots and call through manager methods, so live Host data
|
|
102
|
+
* never crosses the client RPC boundary by accident.
|
|
103
|
+
*/
|
|
104
|
+
export class McpClientManager {
|
|
105
|
+
constructor(
|
|
106
|
+
settingsScope,
|
|
107
|
+
{
|
|
108
|
+
connectionFactory = createMcpConnection,
|
|
109
|
+
schedule,
|
|
110
|
+
now = Date.now,
|
|
111
|
+
} = {},
|
|
112
|
+
) {
|
|
113
|
+
if (typeof schedule !== 'function') {
|
|
114
|
+
throw new TypeError('McpClientManager requires a lifecycle-owned schedule(callback, delay)')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.settingsScope = settingsScope
|
|
118
|
+
this.connectionFactory = connectionFactory
|
|
119
|
+
this.schedule = schedule
|
|
120
|
+
this.now = now
|
|
121
|
+
this.records = new Map()
|
|
122
|
+
this.listeners = new Set()
|
|
123
|
+
this.disposed = false
|
|
124
|
+
|
|
125
|
+
this.installConfig(settingsScope.get())
|
|
126
|
+
this.unwatch = settingsScope.watch((next) => this.reconcile(next))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
installConfig(settings) {
|
|
130
|
+
for (const [name, config] of Object.entries(settings.mcpServers)) {
|
|
131
|
+
this.records.set(name, createRecord(name, config))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async reconcile(settings) {
|
|
136
|
+
if (this.disposed) return
|
|
137
|
+
const nextServers = settings.mcpServers
|
|
138
|
+
|
|
139
|
+
for (const [name, record] of [...this.records]) {
|
|
140
|
+
const next = nextServers[name]
|
|
141
|
+
if (next === undefined) {
|
|
142
|
+
await this.closeRecord(record, 'removed')
|
|
143
|
+
this.records.delete(name)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (!equalJson(record.config, next)) {
|
|
147
|
+
const mustReconnect = !equalJson(
|
|
148
|
+
connectionConfig(record.config),
|
|
149
|
+
connectionConfig(next),
|
|
150
|
+
)
|
|
151
|
+
const idleTimeoutChanged =
|
|
152
|
+
record.config.idleTimeoutMinutes !== next.idleTimeoutMinutes
|
|
153
|
+
if (mustReconnect) {
|
|
154
|
+
await this.closeRecord(record, 'changed')
|
|
155
|
+
record.state = initialState(next)
|
|
156
|
+
record.message = undefined
|
|
157
|
+
record.transportType = undefined
|
|
158
|
+
record.tools = []
|
|
159
|
+
}
|
|
160
|
+
record.config = next
|
|
161
|
+
if (!mustReconnect && idleTimeoutChanged) this.scheduleIdle(record)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const [name, config] of Object.entries(nextServers)) {
|
|
166
|
+
if (!this.records.has(name)) this.records.set(name, createRecord(name, config))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.emit()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
subscribe(listener) {
|
|
173
|
+
this.listeners.add(listener)
|
|
174
|
+
return () => this.listeners.delete(listener)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
emit() {
|
|
178
|
+
const snapshot = this.statusSnapshot()
|
|
179
|
+
for (const listener of this.listeners) {
|
|
180
|
+
try {
|
|
181
|
+
listener(snapshot)
|
|
182
|
+
} catch {
|
|
183
|
+
// Status observers cannot break connection ownership.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
requireRecord(name) {
|
|
189
|
+
const record = this.records.get(name)
|
|
190
|
+
if (record === undefined) throw new Error(`Unknown MCP server ${JSON.stringify(name)}`)
|
|
191
|
+
if (record.config.disabled) throw new Error(`MCP server ${JSON.stringify(name)} is disabled`)
|
|
192
|
+
return record
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getServerConfig(name) {
|
|
196
|
+
const record = this.records.get(name)
|
|
197
|
+
return record === undefined ? undefined : cloneJson(record.config)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getCachedTools(name) {
|
|
201
|
+
const record = this.records.get(name)
|
|
202
|
+
return record === undefined ? [] : cloneJson(record.tools)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
statusSnapshot() {
|
|
206
|
+
return {
|
|
207
|
+
servers: [...this.records.values()].map((record) => ({
|
|
208
|
+
name: record.name,
|
|
209
|
+
state: record.state,
|
|
210
|
+
...(record.message === undefined ? {} : { message: record.message }),
|
|
211
|
+
...(record.transportType === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: { transport: record.transportType }),
|
|
214
|
+
toolCount: record.tools.length,
|
|
215
|
+
...(record.lastUsedAt === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: { lastUsedAt: record.lastUsedAt }),
|
|
218
|
+
})),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
catalogSnapshot() {
|
|
223
|
+
return {
|
|
224
|
+
servers: [...this.records.values()].map((record) => ({
|
|
225
|
+
name: record.name,
|
|
226
|
+
disabled: record.config.disabled,
|
|
227
|
+
state: record.state,
|
|
228
|
+
tools: cloneJson(record.tools),
|
|
229
|
+
})),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setState(record, state, message) {
|
|
234
|
+
record.state = state
|
|
235
|
+
record.message = message
|
|
236
|
+
this.emit()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
cancelIdle(record) {
|
|
240
|
+
record.cancelIdle?.()
|
|
241
|
+
record.cancelIdle = undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
scheduleIdle(record) {
|
|
245
|
+
this.cancelIdle(record)
|
|
246
|
+
if (
|
|
247
|
+
this.disposed ||
|
|
248
|
+
record.connection === undefined ||
|
|
249
|
+
record.activeCalls > 0
|
|
250
|
+
) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const delay = record.config.idleTimeoutMinutes * 60_000
|
|
255
|
+
record.cancelIdle = this.schedule(async () => {
|
|
256
|
+
record.cancelIdle = undefined
|
|
257
|
+
if (record.activeCalls > 0) {
|
|
258
|
+
this.scheduleIdle(record)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
await this.disconnect(record.name, 'idle timeout')
|
|
262
|
+
}, delay)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
touch(record) {
|
|
266
|
+
record.lastUsedAt = this.now()
|
|
267
|
+
this.scheduleIdle(record)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
callbacksFor(record, generation) {
|
|
271
|
+
return {
|
|
272
|
+
onToolsChanged: (error, tools) => {
|
|
273
|
+
if (record.generation !== generation || this.disposed) return
|
|
274
|
+
if (error !== null) {
|
|
275
|
+
this.setState(record, 'error', `Tool list refresh failed: ${errorMessage(error)}`)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (tools !== null) {
|
|
279
|
+
record.tools = tools.map(normalizeTool)
|
|
280
|
+
record.message = undefined
|
|
281
|
+
if (record.connection !== undefined) record.state = 'connected'
|
|
282
|
+
this.emit()
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
onError: (error) => {
|
|
286
|
+
if (record.generation !== generation || this.disposed) return
|
|
287
|
+
this.setState(record, 'error', errorMessage(error))
|
|
288
|
+
},
|
|
289
|
+
onClose: () => {
|
|
290
|
+
if (record.generation !== generation || this.disposed) return
|
|
291
|
+
record.connection = undefined
|
|
292
|
+
record.transportType = undefined
|
|
293
|
+
this.cancelIdle(record)
|
|
294
|
+
this.setState(record, 'disconnected', 'Connection closed')
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async ensureConnection(record, signal) {
|
|
300
|
+
if (record.connection !== undefined) return record.connection
|
|
301
|
+
if (record.connectPromise !== undefined) {
|
|
302
|
+
return waitWithSignal(record.connectPromise, signal)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const generation = ++record.generation
|
|
306
|
+
const controller = new AbortController()
|
|
307
|
+
record.connectAbort = controller
|
|
308
|
+
this.setState(record, 'connecting')
|
|
309
|
+
|
|
310
|
+
const promise = (async () => {
|
|
311
|
+
let connection
|
|
312
|
+
try {
|
|
313
|
+
connection = await this.connectionFactory(
|
|
314
|
+
record.name,
|
|
315
|
+
record.config,
|
|
316
|
+
this.callbacksFor(record, generation),
|
|
317
|
+
controller.signal,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if (
|
|
321
|
+
this.disposed ||
|
|
322
|
+
record.generation !== generation ||
|
|
323
|
+
record.config.disabled
|
|
324
|
+
) {
|
|
325
|
+
throw new Error(`MCP server ${JSON.stringify(record.name)} changed while connecting`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
record.connection = connection
|
|
329
|
+
record.transportType = connection.transportType
|
|
330
|
+
record.state = 'connected'
|
|
331
|
+
record.message = undefined
|
|
332
|
+
|
|
333
|
+
const listed = await connection.client.listTools(undefined, {
|
|
334
|
+
signal: controller.signal,
|
|
335
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
336
|
+
})
|
|
337
|
+
record.tools = listed.tools.map(normalizeTool)
|
|
338
|
+
this.touch(record)
|
|
339
|
+
this.emit()
|
|
340
|
+
return connection
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (connection !== undefined) await connection.close()
|
|
343
|
+
if (record.generation === generation && !this.disposed) {
|
|
344
|
+
record.connection = undefined
|
|
345
|
+
record.transportType = undefined
|
|
346
|
+
this.setState(record, 'error', errorMessage(error))
|
|
347
|
+
}
|
|
348
|
+
throw error
|
|
349
|
+
} finally {
|
|
350
|
+
if (record.generation === generation) record.connectAbort = undefined
|
|
351
|
+
}
|
|
352
|
+
})()
|
|
353
|
+
|
|
354
|
+
record.connectPromise = promise
|
|
355
|
+
const clearPromise = () => {
|
|
356
|
+
if (record.connectPromise === promise) record.connectPromise = undefined
|
|
357
|
+
}
|
|
358
|
+
promise.then(clearPromise, clearPromise)
|
|
359
|
+
return waitWithSignal(promise, signal)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async withConnection(name, signal, operation) {
|
|
363
|
+
if (signal?.aborted) throw abortError(signal)
|
|
364
|
+
const record = this.requireRecord(name)
|
|
365
|
+
record.activeCalls += 1
|
|
366
|
+
this.cancelIdle(record)
|
|
367
|
+
try {
|
|
368
|
+
const connection = await this.ensureConnection(record, signal)
|
|
369
|
+
return await operation(connection, record)
|
|
370
|
+
} finally {
|
|
371
|
+
record.activeCalls -= 1
|
|
372
|
+
this.touch(record)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async listTools(name, { refresh = false, signal } = {}) {
|
|
377
|
+
return this.withConnection(name, signal, async (connection, record) => {
|
|
378
|
+
if (refresh) {
|
|
379
|
+
const listed = await connection.client.listTools(undefined, {
|
|
380
|
+
signal,
|
|
381
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
382
|
+
})
|
|
383
|
+
record.tools = listed.tools.map(normalizeTool)
|
|
384
|
+
this.emit()
|
|
385
|
+
}
|
|
386
|
+
return cloneJson(record.tools)
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async callTool(name, toolName, args = {}, { signal } = {}) {
|
|
391
|
+
return this.withConnection(name, signal, async (connection) => {
|
|
392
|
+
return connection.client.callTool(
|
|
393
|
+
{ name: toolName, arguments: args },
|
|
394
|
+
undefined,
|
|
395
|
+
{ signal, timeout: REQUEST_TIMEOUT_MS },
|
|
396
|
+
)
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async disconnect(name, reason = 'disconnected') {
|
|
401
|
+
const record = this.records.get(name)
|
|
402
|
+
if (record === undefined) return false
|
|
403
|
+
await this.closeRecord(record, reason)
|
|
404
|
+
if (record.config.disabled) {
|
|
405
|
+
this.setState(record, 'disabled')
|
|
406
|
+
} else {
|
|
407
|
+
this.setState(record, 'disconnected')
|
|
408
|
+
}
|
|
409
|
+
return true
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async closeRecord(record) {
|
|
413
|
+
record.generation += 1
|
|
414
|
+
this.cancelIdle(record)
|
|
415
|
+
record.connectAbort?.abort()
|
|
416
|
+
record.connectAbort = undefined
|
|
417
|
+
const connection = record.connection
|
|
418
|
+
const connecting = record.connectPromise
|
|
419
|
+
record.connection = undefined
|
|
420
|
+
record.connectPromise = undefined
|
|
421
|
+
record.transportType = undefined
|
|
422
|
+
if (connection !== undefined) await connection.close()
|
|
423
|
+
if (connecting !== undefined) await connecting.catch(() => undefined)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async dispose() {
|
|
427
|
+
if (this.disposed) return
|
|
428
|
+
this.disposed = true
|
|
429
|
+
this.unwatch?.()
|
|
430
|
+
await Promise.all([...this.records.values()].map((record) => this.closeRecord(record)))
|
|
431
|
+
this.listeners.clear()
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export const MCP_RPC_CHANNEL = '/mcp-adapter'
|
|
436
|
+
|
|
437
|
+
export function installMcpManagerRpc(ctx, manager) {
|
|
438
|
+
ctx.connection.rpc.handle(
|
|
439
|
+
MCP_RPC_CHANNEL,
|
|
440
|
+
async (endpoint, payload, signal) => {
|
|
441
|
+
if (endpoint === 'status') {
|
|
442
|
+
return { ok: true, value: manager.statusSnapshot() }
|
|
443
|
+
}
|
|
444
|
+
if (endpoint === 'catalog') {
|
|
445
|
+
return { ok: true, value: manager.catalogSnapshot() }
|
|
446
|
+
}
|
|
447
|
+
if (endpoint === 'overview') {
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
value: {
|
|
451
|
+
status: manager.statusSnapshot(),
|
|
452
|
+
catalog: manager.catalogSnapshot(),
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (endpoint === 'reconnect') {
|
|
457
|
+
if (
|
|
458
|
+
typeof payload !== 'object' ||
|
|
459
|
+
payload === null ||
|
|
460
|
+
Array.isArray(payload) ||
|
|
461
|
+
typeof payload.server !== 'string' ||
|
|
462
|
+
payload.server.trim() === ''
|
|
463
|
+
) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
error: {
|
|
467
|
+
code: 'bad-request',
|
|
468
|
+
message: 'Reconnect requires a non-empty Server name.',
|
|
469
|
+
details: { issues: [] },
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const serverName = payload.server.trim()
|
|
474
|
+
try {
|
|
475
|
+
await manager.disconnect(serverName, 'manual reconnect')
|
|
476
|
+
await manager.listTools(serverName, { signal })
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
value: {
|
|
480
|
+
status: manager.statusSnapshot(),
|
|
481
|
+
catalog: manager.catalogSnapshot(),
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
} catch (error) {
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
error: {
|
|
488
|
+
code: 'mcp-reconnect-failed',
|
|
489
|
+
message: errorMessage(error),
|
|
490
|
+
details: { issues: [] },
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
error: {
|
|
498
|
+
code: 'bad-request',
|
|
499
|
+
message: `Unknown MCP Adapter endpoint ${JSON.stringify(endpoint)}`,
|
|
500
|
+
details: { issues: [] },
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
{ authority: 'trusted-host' },
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function installMcpManager(ctx, settingsScope, options = {}) {
|
|
509
|
+
const manager = new McpClientManager(settingsScope, {
|
|
510
|
+
schedule: (callback, delay) => ctx.timeout(callback, delay),
|
|
511
|
+
...options,
|
|
512
|
+
})
|
|
513
|
+
ctx.provide('mcpManager', manager)
|
|
514
|
+
ctx.effect(() => () => manager.dispose(), 'dsh-mcp-adapter: manager lifecycle')
|
|
515
|
+
return manager
|
|
516
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
2
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
|
3
|
+
import {
|
|
4
|
+
StdioClientTransport,
|
|
5
|
+
getDefaultEnvironment,
|
|
6
|
+
} from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
7
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
8
|
+
|
|
9
|
+
export const CONNECT_TIMEOUT_MS = 30_000
|
|
10
|
+
export const REQUEST_TIMEOUT_MS = 30_000
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SDK = {
|
|
13
|
+
Client,
|
|
14
|
+
SSEClientTransport,
|
|
15
|
+
StdioClientTransport,
|
|
16
|
+
StreamableHTTPClientTransport,
|
|
17
|
+
getDefaultEnvironment,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function errorMessage(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createClient(serverName, callbacks, sdk) {
|
|
25
|
+
const client = new sdk.Client(
|
|
26
|
+
{ name: 'dsh-mcp-adapter', version: '0.1.0' },
|
|
27
|
+
{
|
|
28
|
+
listChanged: {
|
|
29
|
+
tools: {
|
|
30
|
+
autoRefresh: true,
|
|
31
|
+
debounceMs: 100,
|
|
32
|
+
onChanged(error, tools) {
|
|
33
|
+
callbacks.onToolsChanged?.(error, tools)
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
client.onerror = (error) => callbacks.onError?.(error)
|
|
41
|
+
client.onclose = () => callbacks.onClose?.()
|
|
42
|
+
return client
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function closeQuietly(client, transport) {
|
|
46
|
+
try {
|
|
47
|
+
await client?.close()
|
|
48
|
+
} catch {
|
|
49
|
+
try {
|
|
50
|
+
await transport?.close()
|
|
51
|
+
} catch {
|
|
52
|
+
// The failed connection has no remaining lifecycle work.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function connectClient(client, transport, signal) {
|
|
58
|
+
await client.connect(transport, {
|
|
59
|
+
signal,
|
|
60
|
+
timeout: CONNECT_TIMEOUT_MS,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create one connected SDK client. HTTP follows the MCP compatibility rule:
|
|
66
|
+
* attempt streamable HTTP first, then retry once with the legacy SSE transport
|
|
67
|
+
* and a fresh Client/Transport pair.
|
|
68
|
+
*
|
|
69
|
+
* `sdk` is injectable for transport-contract tests. Production callers omit it.
|
|
70
|
+
*/
|
|
71
|
+
export async function createMcpConnection(
|
|
72
|
+
serverName,
|
|
73
|
+
config,
|
|
74
|
+
callbacks = {},
|
|
75
|
+
signal,
|
|
76
|
+
sdk = DEFAULT_SDK,
|
|
77
|
+
) {
|
|
78
|
+
if (typeof config.command === 'string') {
|
|
79
|
+
const client = createClient(serverName, callbacks, sdk)
|
|
80
|
+
const transport = new sdk.StdioClientTransport({
|
|
81
|
+
command: config.command,
|
|
82
|
+
args: config.args,
|
|
83
|
+
env: {
|
|
84
|
+
...sdk.getDefaultEnvironment(),
|
|
85
|
+
...config.env,
|
|
86
|
+
},
|
|
87
|
+
stderr: 'pipe',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
let stderr = ''
|
|
91
|
+
transport.stderr?.on?.('data', (chunk) => {
|
|
92
|
+
stderr = `${stderr}${String(chunk)}`.slice(-8192)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await connectClient(client, transport, signal)
|
|
97
|
+
return {
|
|
98
|
+
client,
|
|
99
|
+
transport,
|
|
100
|
+
transportType: 'stdio',
|
|
101
|
+
diagnostic: () => stderr.trim(),
|
|
102
|
+
close: () => closeQuietly(client, transport),
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
await closeQuietly(client, transport)
|
|
106
|
+
const detail = stderr.trim()
|
|
107
|
+
if (detail === '') throw error
|
|
108
|
+
throw new Error(`${errorMessage(error)}\nServer stderr:\n${detail}`, {
|
|
109
|
+
cause: error,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const url = new URL(config.url)
|
|
115
|
+
const requestInit = { headers: { ...config.headers } }
|
|
116
|
+
|
|
117
|
+
let streamableClient = createClient(serverName, callbacks, sdk)
|
|
118
|
+
let streamableTransport = new sdk.StreamableHTTPClientTransport(url, {
|
|
119
|
+
requestInit,
|
|
120
|
+
})
|
|
121
|
+
let streamableError
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await connectClient(streamableClient, streamableTransport, signal)
|
|
125
|
+
return {
|
|
126
|
+
client: streamableClient,
|
|
127
|
+
transport: streamableTransport,
|
|
128
|
+
transportType: 'streamable-http',
|
|
129
|
+
diagnostic: () => '',
|
|
130
|
+
close: () => closeQuietly(streamableClient, streamableTransport),
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
streamableError = error
|
|
134
|
+
await closeQuietly(streamableClient, streamableTransport)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sseClient = createClient(serverName, callbacks, sdk)
|
|
138
|
+
const sseTransport = new sdk.SSEClientTransport(url, { requestInit })
|
|
139
|
+
try {
|
|
140
|
+
await connectClient(sseClient, sseTransport, signal)
|
|
141
|
+
return {
|
|
142
|
+
client: sseClient,
|
|
143
|
+
transport: sseTransport,
|
|
144
|
+
transportType: 'sse',
|
|
145
|
+
diagnostic: () => '',
|
|
146
|
+
close: () => closeQuietly(sseClient, sseTransport),
|
|
147
|
+
}
|
|
148
|
+
} catch (sseError) {
|
|
149
|
+
await closeQuietly(sseClient, sseTransport)
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Could not connect to ${serverName} with streamable HTTP or SSE. ` +
|
|
152
|
+
`Streamable HTTP: ${errorMessage(streamableError)}. ` +
|
|
153
|
+
`SSE: ${errorMessage(sseError)}.`,
|
|
154
|
+
{ cause: sseError },
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|