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