@onmars/lunar-mcp 0.6.2 → 0.8.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/package.json +2 -4
- package/src/__tests__/connection.test.ts +3 -3
- package/src/__tests__/mock-mcp-server.ts +1 -1
- package/src/index.ts +22 -6
- package/src/lib/connection.ts +146 -135
- package/src/lib/registry.ts +2 -0
- package/src/lib/transports.ts +485 -0
- package/src/lib/types.ts +51 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onmars/lunar-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -11,9 +11,7 @@
|
|
|
11
11
|
"src/",
|
|
12
12
|
"LICENSE"
|
|
13
13
|
],
|
|
14
|
-
"dependencies": {
|
|
15
|
-
"@onmars/lunar-core": "^0.6.0"
|
|
16
|
-
},
|
|
14
|
+
"dependencies": {},
|
|
17
15
|
"description": "MCP (Model Context Protocol) client for Lunar",
|
|
18
16
|
"author": "onMars Tech",
|
|
19
17
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@ function createMockProcess() {
|
|
|
53
53
|
result = {
|
|
54
54
|
protocolVersion: '2025-11-25',
|
|
55
55
|
capabilities: { tools: { listChanged: false } },
|
|
56
|
-
serverInfo: { name: 'mock-
|
|
56
|
+
serverInfo: { name: 'mock-mcp-server', version: '0.1.0' },
|
|
57
57
|
}
|
|
58
58
|
} else if (method === 'tools/list') {
|
|
59
59
|
result = {
|
|
@@ -187,7 +187,7 @@ describe('MCPConnection', () => {
|
|
|
187
187
|
conn = new MCPConnection(mockConfig())
|
|
188
188
|
await conn.connect()
|
|
189
189
|
expect(conn.state).toBe('ready')
|
|
190
|
-
expect(conn.serverInfo?.name).toBe('mock-
|
|
190
|
+
expect(conn.serverInfo?.name).toBe('mock-mcp-server')
|
|
191
191
|
expect(conn.serverInfo?.version).toBe('0.1.0')
|
|
192
192
|
})
|
|
193
193
|
|
|
@@ -271,7 +271,7 @@ describe('MCPRegistry', () => {
|
|
|
271
271
|
expect(status).toHaveLength(1)
|
|
272
272
|
expect(status[0].id).toBe('server1')
|
|
273
273
|
expect(status[0].state).toBe('ready')
|
|
274
|
-
expect(status[0].serverName).toBe('mock-
|
|
274
|
+
expect(status[0].serverName).toBe('mock-mcp-server')
|
|
275
275
|
expect(status[0].toolCount).toBe(3)
|
|
276
276
|
})
|
|
277
277
|
|
|
@@ -60,7 +60,7 @@ rl.on('line', (line) => {
|
|
|
60
60
|
respond(msg.id, {
|
|
61
61
|
protocolVersion: '2025-11-25',
|
|
62
62
|
capabilities: { tools: { listChanged: false } },
|
|
63
|
-
serverInfo: { name: 'mock-
|
|
63
|
+
serverInfo: { name: 'mock-mcp-server', version: '0.1.0' },
|
|
64
64
|
})
|
|
65
65
|
return
|
|
66
66
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,24 +10,35 @@
|
|
|
10
10
|
*
|
|
11
11
|
* const registry = new MCPRegistry()
|
|
12
12
|
*
|
|
13
|
-
* // Connect to
|
|
13
|
+
* // Connect to any MCP server (stdio)
|
|
14
14
|
* await registry.connect({
|
|
15
|
-
* id: '
|
|
16
|
-
* command: '
|
|
17
|
-
* role: '
|
|
15
|
+
* id: 'my-tools',
|
|
16
|
+
* command: 'my-mcp-server',
|
|
17
|
+
* role: 'tools',
|
|
18
18
|
* })
|
|
19
19
|
*
|
|
20
20
|
* // List all tools
|
|
21
21
|
* const tools = registry.listTools()
|
|
22
22
|
*
|
|
23
23
|
* // Call a tool (auto-routes to correct server)
|
|
24
|
-
* await registry.callTool('
|
|
24
|
+
* await registry.callTool('search', { query: 'auth middleware' })
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
// Core
|
|
29
|
-
export { type ConnectionState, MCPConnection } from './lib/connection'
|
|
29
|
+
export { type ConnectionState, MCPConnection, resolveTransportConfig } from './lib/connection'
|
|
30
30
|
export { type MCPConnectionStatus, MCPRegistry } from './lib/registry'
|
|
31
|
+
export {
|
|
32
|
+
HttpTransport,
|
|
33
|
+
type HttpTransportOptions,
|
|
34
|
+
type InboundMessage,
|
|
35
|
+
type MCPTransport,
|
|
36
|
+
type OutboundMessage,
|
|
37
|
+
SseTransport,
|
|
38
|
+
type SseTransportOptions,
|
|
39
|
+
StdioTransport,
|
|
40
|
+
type StdioTransportOptions,
|
|
41
|
+
} from './lib/transports'
|
|
31
42
|
|
|
32
43
|
// Types
|
|
33
44
|
export type {
|
|
@@ -40,8 +51,13 @@ export type {
|
|
|
40
51
|
JsonRpcNotification,
|
|
41
52
|
JsonRpcRequest,
|
|
42
53
|
JsonRpcResponse,
|
|
54
|
+
MCPHttpConfig,
|
|
55
|
+
MCPSseConfig,
|
|
43
56
|
MCPServerConfig,
|
|
57
|
+
MCPStdioConfig,
|
|
44
58
|
MCPTool,
|
|
59
|
+
MCPTransportConfig,
|
|
60
|
+
MCPTransportKind,
|
|
45
61
|
ServerCapabilities,
|
|
46
62
|
ServerInfo,
|
|
47
63
|
ToolCallParams,
|
package/src/lib/connection.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCPConnection — Single connection to an MCP server
|
|
2
|
+
* MCPConnection — Single connection to an MCP server
|
|
3
3
|
*
|
|
4
4
|
* Manages:
|
|
5
|
-
* -
|
|
6
|
-
* - JSON-RPC 2.0
|
|
5
|
+
* - Transport lifecycle (stdio subprocess, HTTP, SSE)
|
|
6
|
+
* - JSON-RPC 2.0 request/response correlation via message IDs
|
|
7
7
|
* - MCP handshake (initialize → initialized)
|
|
8
8
|
* - Tool discovery and invocation
|
|
9
|
-
* - Request/response correlation via message IDs
|
|
10
|
-
* - Newline-delimited JSON framing
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* The actual IO is delegated to an MCPTransport (stdio/http/sse).
|
|
11
|
+
* MCPRegistry manages multiple MCPConnection instances.
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
|
-
import { type ChildProcess, spawn } from 'node:child_process'
|
|
17
14
|
import { EventEmitter } from 'node:events'
|
|
15
|
+
import {
|
|
16
|
+
HttpTransport,
|
|
17
|
+
type MCPTransport,
|
|
18
|
+
type OutboundMessage,
|
|
19
|
+
type InboundMessage,
|
|
20
|
+
SseTransport,
|
|
21
|
+
StdioTransport,
|
|
22
|
+
} from './transports'
|
|
18
23
|
import type {
|
|
19
24
|
InitializeResult,
|
|
20
25
|
JsonRpcNotification,
|
|
@@ -22,6 +27,7 @@ import type {
|
|
|
22
27
|
JsonRpcResponse,
|
|
23
28
|
MCPServerConfig,
|
|
24
29
|
MCPTool,
|
|
30
|
+
MCPTransportConfig,
|
|
25
31
|
ServerCapabilities,
|
|
26
32
|
ServerInfo,
|
|
27
33
|
ToolCallResult,
|
|
@@ -29,10 +35,48 @@ import type {
|
|
|
29
35
|
|
|
30
36
|
export type ConnectionState = 'disconnected' | 'connecting' | 'ready' | 'error'
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a transport-config from an MCPServerConfig.
|
|
40
|
+
* Accepts the new discriminated `transport` field or falls back to the
|
|
41
|
+
* legacy stdio shorthand (`command` + `args`).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveTransportConfig(config: MCPServerConfig): MCPTransportConfig {
|
|
44
|
+
if (config.transport) return config.transport
|
|
45
|
+
if (config.command) {
|
|
46
|
+
return {
|
|
47
|
+
transport: 'stdio',
|
|
48
|
+
command: config.command,
|
|
49
|
+
args: config.args,
|
|
50
|
+
cwd: config.cwd,
|
|
51
|
+
env: config.env,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(
|
|
55
|
+
`MCPConnection "${config.id}": no transport or command configured`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildTransport(config: MCPTransportConfig): MCPTransport {
|
|
60
|
+
switch (config.transport) {
|
|
61
|
+
case 'stdio':
|
|
62
|
+
return new StdioTransport({
|
|
63
|
+
command: config.command,
|
|
64
|
+
args: config.args,
|
|
65
|
+
cwd: config.cwd,
|
|
66
|
+
env: config.env,
|
|
67
|
+
})
|
|
68
|
+
case 'http':
|
|
69
|
+
return new HttpTransport({ url: config.url, headers: config.headers })
|
|
70
|
+
case 'sse':
|
|
71
|
+
return new SseTransport({ url: config.url, headers: config.headers })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
32
75
|
export class MCPConnection extends EventEmitter {
|
|
33
76
|
readonly id: string
|
|
34
77
|
private config: MCPServerConfig
|
|
35
|
-
private
|
|
78
|
+
private transport: MCPTransport | null = null
|
|
79
|
+
private transportKind: 'stdio' | 'http' | 'sse' = 'stdio'
|
|
36
80
|
private nextId = 1
|
|
37
81
|
private pending = new Map<
|
|
38
82
|
number | string,
|
|
@@ -42,7 +86,6 @@ export class MCPConnection extends EventEmitter {
|
|
|
42
86
|
timer: ReturnType<typeof setTimeout>
|
|
43
87
|
}
|
|
44
88
|
>()
|
|
45
|
-
private buffer = ''
|
|
46
89
|
private _state: ConnectionState = 'disconnected'
|
|
47
90
|
private _tools: MCPTool[] = []
|
|
48
91
|
private _serverInfo: ServerInfo | null = null
|
|
@@ -60,6 +103,10 @@ export class MCPConnection extends EventEmitter {
|
|
|
60
103
|
get capabilities(): ServerCapabilities | null {
|
|
61
104
|
return this._capabilities
|
|
62
105
|
}
|
|
106
|
+
/** Which transport is in use (for status output / tests). */
|
|
107
|
+
get transportType(): 'stdio' | 'http' | 'sse' {
|
|
108
|
+
return this.transportKind
|
|
109
|
+
}
|
|
63
110
|
|
|
64
111
|
constructor(config: MCPServerConfig) {
|
|
65
112
|
super()
|
|
@@ -68,7 +115,7 @@ export class MCPConnection extends EventEmitter {
|
|
|
68
115
|
}
|
|
69
116
|
|
|
70
117
|
/**
|
|
71
|
-
*
|
|
118
|
+
* Open the transport and perform the MCP handshake.
|
|
72
119
|
* After this resolves, the connection is ready for tool calls.
|
|
73
120
|
*/
|
|
74
121
|
async connect(): Promise<void> {
|
|
@@ -76,66 +123,66 @@ export class MCPConnection extends EventEmitter {
|
|
|
76
123
|
this._state = 'connecting'
|
|
77
124
|
|
|
78
125
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
env: { ...process.env, ...this.config.env },
|
|
88
|
-
})
|
|
89
|
-
} catch (spawnError) {
|
|
90
|
-
this._state = 'error'
|
|
91
|
-
throw spawnError
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Handle subprocess events
|
|
95
|
-
this.process.on('exit', (code, signal) => {
|
|
126
|
+
const transportConfig = resolveTransportConfig(this.config)
|
|
127
|
+
this.transportKind = transportConfig.transport
|
|
128
|
+
this.transport = buildTransport(transportConfig)
|
|
129
|
+
|
|
130
|
+
// Forward transport events
|
|
131
|
+
this.transport.on('message', (msg) => this.handleMessage(msg))
|
|
132
|
+
this.transport.on('log', (line) => this.emit('log', line))
|
|
133
|
+
this.transport.on('exit', (info) => {
|
|
96
134
|
this._state = 'disconnected'
|
|
97
|
-
this.rejectAllPending(
|
|
98
|
-
|
|
135
|
+
this.rejectAllPending(
|
|
136
|
+
new Error(`MCP transport exited (code=${info.code}, signal=${info.signal})`),
|
|
137
|
+
)
|
|
138
|
+
this.emit('exit', info)
|
|
99
139
|
})
|
|
100
|
-
|
|
101
|
-
this.process.on('error', (err) => {
|
|
140
|
+
this.transport.on('error', (err) => {
|
|
102
141
|
this._state = 'error'
|
|
103
142
|
this.rejectAllPending(err)
|
|
104
143
|
this.emit('error', err)
|
|
105
144
|
})
|
|
106
145
|
|
|
107
|
-
|
|
108
|
-
this.process.stderr?.on('data', (chunk: Buffer) => {
|
|
109
|
-
this.emit('log', chunk.toString())
|
|
110
|
-
})
|
|
146
|
+
await this.transport.start()
|
|
111
147
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
148
|
+
// Race the MCP handshake against early transport failure so broken
|
|
149
|
+
// binaries / unreachable URLs surface instantly instead of timing
|
|
150
|
+
// out at the request level.
|
|
151
|
+
const earlyFailure = new Promise<never>((_, reject) => {
|
|
152
|
+
const fail = (err: Error) => {
|
|
153
|
+
this.transport?.off('error', fail)
|
|
154
|
+
this.transport?.off('exit', onExit)
|
|
155
|
+
reject(err)
|
|
156
|
+
}
|
|
157
|
+
const onExit = () => fail(new Error(`transport exited before handshake completed`))
|
|
158
|
+
this.transport?.once('error', fail)
|
|
159
|
+
this.transport?.once('exit', onExit)
|
|
116
160
|
})
|
|
117
161
|
|
|
118
|
-
//
|
|
119
|
-
const initResult = (await
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
162
|
+
// MCP handshake: initialize
|
|
163
|
+
const initResult = (await Promise.race([
|
|
164
|
+
this.request(
|
|
165
|
+
'initialize',
|
|
166
|
+
{
|
|
167
|
+
protocolVersion: '2025-11-25',
|
|
168
|
+
capabilities: {},
|
|
169
|
+
clientInfo: {
|
|
170
|
+
name: 'lunar',
|
|
171
|
+
version: '0.7.0',
|
|
172
|
+
},
|
|
127
173
|
},
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
174
|
+
this.config.timeoutMs ?? 10000,
|
|
175
|
+
),
|
|
176
|
+
earlyFailure,
|
|
177
|
+
])) as InitializeResult
|
|
131
178
|
|
|
132
179
|
this._serverInfo = initResult.serverInfo
|
|
133
180
|
this._capabilities = initResult.capabilities
|
|
134
181
|
|
|
135
|
-
//
|
|
182
|
+
// Send initialized notification
|
|
136
183
|
this.notify('notifications/initialized')
|
|
137
184
|
|
|
138
|
-
//
|
|
185
|
+
// Discover tools
|
|
139
186
|
if (initResult.capabilities?.tools) {
|
|
140
187
|
const toolsResult = (await this.request('tools/list', {})) as { tools: MCPTool[] }
|
|
141
188
|
this._tools = toolsResult.tools || []
|
|
@@ -145,10 +192,11 @@ export class MCPConnection extends EventEmitter {
|
|
|
145
192
|
this.emit('ready', {
|
|
146
193
|
serverInfo: this._serverInfo,
|
|
147
194
|
tools: this._tools.length,
|
|
195
|
+
transport: this.transportKind,
|
|
148
196
|
})
|
|
149
197
|
} catch (err) {
|
|
150
198
|
this._state = 'error'
|
|
151
|
-
this.
|
|
199
|
+
await this.safeStopTransport()
|
|
152
200
|
throw err
|
|
153
201
|
}
|
|
154
202
|
}
|
|
@@ -215,55 +263,33 @@ export class MCPConnection extends EventEmitter {
|
|
|
215
263
|
}
|
|
216
264
|
|
|
217
265
|
/**
|
|
218
|
-
* Write a JSON-RPC message to the
|
|
219
|
-
* MCP uses newline-delimited JSON over stdio.
|
|
266
|
+
* Write a JSON-RPC message to the underlying transport.
|
|
220
267
|
*/
|
|
221
|
-
private send(message:
|
|
222
|
-
if (!this.
|
|
223
|
-
throw new Error(`MCP connection "${this.id}"
|
|
224
|
-
}
|
|
225
|
-
const json = JSON.stringify(message)
|
|
226
|
-
this.process.stdin.write(json + '\n')
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Process buffered stdout data, extracting complete JSON-RPC messages.
|
|
231
|
-
* MCP uses newline-delimited JSON (one message per line).
|
|
232
|
-
*/
|
|
233
|
-
private processBuffer(): void {
|
|
234
|
-
const lines = this.buffer.split('\n')
|
|
235
|
-
// Keep the last incomplete line in the buffer
|
|
236
|
-
this.buffer = lines.pop() || ''
|
|
237
|
-
|
|
238
|
-
for (const line of lines) {
|
|
239
|
-
const trimmed = line.trim()
|
|
240
|
-
if (!trimmed) continue
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
const message = JSON.parse(trimmed)
|
|
244
|
-
this.handleMessage(message)
|
|
245
|
-
} catch {
|
|
246
|
-
// Non-JSON output (e.g., startup messages) — ignore
|
|
247
|
-
this.emit('log', `[non-json] ${trimmed}`)
|
|
248
|
-
}
|
|
268
|
+
private send(message: OutboundMessage): void {
|
|
269
|
+
if (!this.transport) {
|
|
270
|
+
throw new Error(`MCP connection "${this.id}" transport not open`)
|
|
249
271
|
}
|
|
272
|
+
this.transport.send(message)
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
/**
|
|
253
276
|
* Handle an incoming JSON-RPC message (response or server notification).
|
|
254
277
|
*/
|
|
255
|
-
private handleMessage(message:
|
|
278
|
+
private handleMessage(message: InboundMessage): void {
|
|
256
279
|
// Response to a pending request
|
|
257
280
|
if ('id' in message && message.id != null) {
|
|
258
|
-
const
|
|
281
|
+
const response = message as JsonRpcResponse
|
|
282
|
+
const pending = this.pending.get(response.id)
|
|
259
283
|
if (pending) {
|
|
260
|
-
this.pending.delete(
|
|
284
|
+
this.pending.delete(response.id)
|
|
261
285
|
clearTimeout(pending.timer)
|
|
262
286
|
|
|
263
|
-
if (
|
|
264
|
-
pending.reject(
|
|
287
|
+
if (response.error) {
|
|
288
|
+
pending.reject(
|
|
289
|
+
new Error(`MCP error [${response.error.code}]: ${response.error.message}`),
|
|
290
|
+
)
|
|
265
291
|
} else {
|
|
266
|
-
pending.resolve(
|
|
292
|
+
pending.resolve(response.result)
|
|
267
293
|
}
|
|
268
294
|
}
|
|
269
295
|
return
|
|
@@ -271,9 +297,10 @@ export class MCPConnection extends EventEmitter {
|
|
|
271
297
|
|
|
272
298
|
// Server-initiated notification
|
|
273
299
|
if ('method' in message) {
|
|
300
|
+
const note = message as JsonRpcNotification
|
|
274
301
|
this.emit('notification', {
|
|
275
|
-
method:
|
|
276
|
-
params:
|
|
302
|
+
method: note.method,
|
|
303
|
+
params: note.params,
|
|
277
304
|
})
|
|
278
305
|
}
|
|
279
306
|
}
|
|
@@ -290,55 +317,39 @@ export class MCPConnection extends EventEmitter {
|
|
|
290
317
|
}
|
|
291
318
|
|
|
292
319
|
/**
|
|
293
|
-
* Graceful shutdown
|
|
320
|
+
* Graceful shutdown.
|
|
294
321
|
*/
|
|
295
322
|
async disconnect(): Promise<void> {
|
|
296
|
-
if (!this.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
let resolved = false
|
|
301
|
-
|
|
302
|
-
const done = () => {
|
|
303
|
-
if (!resolved) {
|
|
304
|
-
resolved = true
|
|
305
|
-
this.process = null
|
|
306
|
-
this._state = 'disconnected'
|
|
307
|
-
this._tools = []
|
|
308
|
-
resolve()
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
proc.on('exit', done)
|
|
313
|
-
|
|
314
|
-
// Step 1: Close stdin
|
|
315
|
-
proc.stdin?.end()
|
|
316
|
-
|
|
317
|
-
// Step 2: SIGTERM after 2s
|
|
318
|
-
setTimeout(() => {
|
|
319
|
-
if (!resolved) proc.kill('SIGTERM')
|
|
320
|
-
}, 2000)
|
|
321
|
-
|
|
322
|
-
// Step 3: SIGKILL after 5s
|
|
323
|
-
setTimeout(() => {
|
|
324
|
-
if (!resolved) {
|
|
325
|
-
proc.kill('SIGKILL')
|
|
326
|
-
done()
|
|
327
|
-
}
|
|
328
|
-
}, 5000)
|
|
329
|
-
})
|
|
323
|
+
if (!this.transport) return
|
|
324
|
+
await this.safeStopTransport()
|
|
325
|
+
this._state = 'disconnected'
|
|
326
|
+
this._tools = []
|
|
330
327
|
}
|
|
331
328
|
|
|
332
329
|
/**
|
|
333
|
-
* Force
|
|
330
|
+
* Force close the transport immediately (stdio → SIGKILL).
|
|
334
331
|
*/
|
|
335
332
|
kill(): void {
|
|
336
|
-
if (this.
|
|
337
|
-
|
|
338
|
-
this.
|
|
339
|
-
|
|
340
|
-
this.
|
|
341
|
-
|
|
333
|
+
if (!this.transport) return
|
|
334
|
+
if (this.transport instanceof StdioTransport) {
|
|
335
|
+
this.transport.kill()
|
|
336
|
+
} else {
|
|
337
|
+
void this.transport.stop().catch(() => {})
|
|
338
|
+
}
|
|
339
|
+
this.transport = null
|
|
340
|
+
this._state = 'disconnected'
|
|
341
|
+
this._tools = []
|
|
342
|
+
this.rejectAllPending(new Error('Connection killed'))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async safeStopTransport(): Promise<void> {
|
|
346
|
+
if (!this.transport) return
|
|
347
|
+
const t = this.transport
|
|
348
|
+
this.transport = null
|
|
349
|
+
try {
|
|
350
|
+
await t.stop()
|
|
351
|
+
} catch {
|
|
352
|
+
// ignore — we're already in teardown
|
|
342
353
|
}
|
|
343
354
|
}
|
|
344
355
|
}
|
package/src/lib/registry.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface MCPConnectionStatus {
|
|
|
21
21
|
serverVersion?: string
|
|
22
22
|
toolCount: number
|
|
23
23
|
role: string
|
|
24
|
+
transport: 'stdio' | 'http' | 'sse'
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export class MCPRegistry extends EventEmitter {
|
|
@@ -190,6 +191,7 @@ export class MCPRegistry extends EventEmitter {
|
|
|
190
191
|
serverVersion: conn.serverInfo?.version,
|
|
191
192
|
toolCount: conn.tools.length,
|
|
192
193
|
role: this.configs.get(id)?.role || 'tools',
|
|
194
|
+
transport: conn.transportType,
|
|
193
195
|
}))
|
|
194
196
|
}
|
|
195
197
|
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP transports — pluggable IO layers for MCPConnection.
|
|
3
|
+
*
|
|
4
|
+
* Each transport exposes the same minimal surface:
|
|
5
|
+
* - start(): open the underlying channel (spawn process / open stream).
|
|
6
|
+
* - send(msg): write one JSON-RPC message.
|
|
7
|
+
* - stop(): graceful shutdown.
|
|
8
|
+
*
|
|
9
|
+
* Incoming messages are delivered via the `message` event (one per JSON-RPC
|
|
10
|
+
* frame). Protocol-level bookkeeping (id correlation, initialize handshake,
|
|
11
|
+
* tool routing) lives in MCPConnection.
|
|
12
|
+
*
|
|
13
|
+
* Implementations here are intentionally dependency-free — they rely only
|
|
14
|
+
* on the Bun / node:* built-ins, so the `@onmars/lunar-mcp` package can be
|
|
15
|
+
* installed without pulling the official MCP SDK. When richer OAuth flows
|
|
16
|
+
* land in Phase 2, a separate transport can be added without touching
|
|
17
|
+
* MCPConnection.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
21
|
+
import { EventEmitter } from 'node:events'
|
|
22
|
+
import type { JsonRpcNotification, JsonRpcRequest, JsonRpcResponse } from './types'
|
|
23
|
+
|
|
24
|
+
export type OutboundMessage = JsonRpcRequest | JsonRpcNotification
|
|
25
|
+
export type InboundMessage = JsonRpcResponse | JsonRpcNotification
|
|
26
|
+
|
|
27
|
+
export interface TransportEvents {
|
|
28
|
+
message: [InboundMessage]
|
|
29
|
+
log: [string]
|
|
30
|
+
exit: [{ code: number | null; signal: string | null }]
|
|
31
|
+
error: [Error]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MCPTransport extends EventEmitter {
|
|
35
|
+
readonly kind: 'stdio' | 'http' | 'sse'
|
|
36
|
+
start(): Promise<void>
|
|
37
|
+
send(message: OutboundMessage): void
|
|
38
|
+
stop(): Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ════════════════════════════════════════════════════════════
|
|
42
|
+
// stdio transport — subprocess, newline-delimited JSON-RPC
|
|
43
|
+
// ════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
export interface StdioTransportOptions {
|
|
46
|
+
command: string
|
|
47
|
+
args?: string[]
|
|
48
|
+
cwd?: string
|
|
49
|
+
env?: Record<string, string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class StdioTransport extends EventEmitter implements MCPTransport {
|
|
53
|
+
readonly kind = 'stdio' as const
|
|
54
|
+
private proc: ChildProcess | null = null
|
|
55
|
+
private buffer = ''
|
|
56
|
+
private procExited = false
|
|
57
|
+
|
|
58
|
+
constructor(private readonly opts: StdioTransportOptions) {
|
|
59
|
+
super()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start(): Promise<void> {
|
|
63
|
+
const [cmd, ...defaultArgs] = this.opts.command.split(' ')
|
|
64
|
+
const args = [...defaultArgs, ...(this.opts.args ?? [])]
|
|
65
|
+
|
|
66
|
+
this.proc = spawn(cmd, args, {
|
|
67
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
|
+
cwd: this.opts.cwd,
|
|
69
|
+
env: { ...process.env, ...this.opts.env },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const emitExit = (code: number | null, signal: string | null) => {
|
|
73
|
+
if (this.procExited) return
|
|
74
|
+
this.procExited = true
|
|
75
|
+
this.emit('exit', { code, signal })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.proc.on('exit', (code, signal) => {
|
|
79
|
+
emitExit(code, signal)
|
|
80
|
+
})
|
|
81
|
+
this.proc.on('error', (err) => {
|
|
82
|
+
// spawn errors (e.g., ENOENT for missing binary) don't emit `exit`
|
|
83
|
+
// on their own. Surface the error AND mark the transport exited so
|
|
84
|
+
// downstream stop() doesn't wait forever.
|
|
85
|
+
this.emit('error', err)
|
|
86
|
+
emitExit(null, null)
|
|
87
|
+
})
|
|
88
|
+
this.proc.stderr?.on('data', (chunk: Buffer) => {
|
|
89
|
+
this.emit('log', chunk.toString())
|
|
90
|
+
})
|
|
91
|
+
this.proc.stdout?.on('data', (chunk: Buffer) => {
|
|
92
|
+
this.buffer += chunk.toString()
|
|
93
|
+
this.drain()
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
send(message: OutboundMessage): void {
|
|
98
|
+
if (!this.proc?.stdin?.writable) {
|
|
99
|
+
throw new Error('StdioTransport: stdin not writable')
|
|
100
|
+
}
|
|
101
|
+
this.proc.stdin.write(`${JSON.stringify(message)}\n`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop(): Promise<void> {
|
|
105
|
+
if (!this.proc) return
|
|
106
|
+
const proc = this.proc
|
|
107
|
+
this.proc = null
|
|
108
|
+
|
|
109
|
+
// If the child already exited (or failed to spawn), bail out fast.
|
|
110
|
+
if (this.procExited || proc.exitCode !== null || proc.signalCode !== null) return
|
|
111
|
+
|
|
112
|
+
return new Promise<void>((resolveStop) => {
|
|
113
|
+
let settled = false
|
|
114
|
+
const done = () => {
|
|
115
|
+
if (!settled) {
|
|
116
|
+
settled = true
|
|
117
|
+
resolveStop()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
proc.on('exit', done)
|
|
121
|
+
proc.on('error', () => {
|
|
122
|
+
// spawn error (ENOENT etc) — treat as done, don't wait 5s
|
|
123
|
+
done()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// If the spawn already failed asynchronously between our `new Promise`
|
|
127
|
+
// and this point, `procExited` is now true. Short-circuit.
|
|
128
|
+
if (this.procExited) {
|
|
129
|
+
done()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
proc.stdin?.end()
|
|
135
|
+
} catch {
|
|
136
|
+
// stdin may already be closed for failed spawns
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Poll on next tick — spawn errors tend to fire immediately after
|
|
140
|
+
// the synchronous spawn() call, so a single microtask check avoids
|
|
141
|
+
// the full 2s SIGTERM wait for broken binaries.
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
if (!settled && this.procExited) done()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
if (!settled) {
|
|
148
|
+
try {
|
|
149
|
+
proc.kill('SIGTERM')
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
}, 2000)
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
if (!settled) {
|
|
155
|
+
try {
|
|
156
|
+
proc.kill('SIGKILL')
|
|
157
|
+
} catch {}
|
|
158
|
+
done()
|
|
159
|
+
}
|
|
160
|
+
}, 5000)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Force-kill the subprocess immediately (used on hard errors).
|
|
166
|
+
*/
|
|
167
|
+
kill(): void {
|
|
168
|
+
if (this.proc) {
|
|
169
|
+
this.proc.kill('SIGKILL')
|
|
170
|
+
this.proc = null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private drain(): void {
|
|
175
|
+
const lines = this.buffer.split('\n')
|
|
176
|
+
this.buffer = lines.pop() ?? ''
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
const trimmed = line.trim()
|
|
179
|
+
if (!trimmed) continue
|
|
180
|
+
try {
|
|
181
|
+
const msg = JSON.parse(trimmed) as InboundMessage
|
|
182
|
+
this.emit('message', msg)
|
|
183
|
+
} catch {
|
|
184
|
+
this.emit('log', `[non-json] ${trimmed}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ════════════════════════════════════════════════════════════
|
|
191
|
+
// HTTP transport (streamable-http)
|
|
192
|
+
// ════════════════════════════════════════════════════════════
|
|
193
|
+
|
|
194
|
+
export interface HttpTransportOptions {
|
|
195
|
+
url: string
|
|
196
|
+
headers?: Record<string, string>
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Streamable-HTTP transport.
|
|
201
|
+
*
|
|
202
|
+
* Minimal implementation of the 2025-11-25 spec:
|
|
203
|
+
* - Every outbound frame is POSTed with `Accept: application/json,
|
|
204
|
+
* text/event-stream`.
|
|
205
|
+
* - Request responses: a single `application/json` body OR a small SSE
|
|
206
|
+
* stream that terminates after the response frame arrives.
|
|
207
|
+
* - Notifications (no `id`): server returns 202 Accepted. We discard.
|
|
208
|
+
*
|
|
209
|
+
* Phase 1 scope: sufficient to talk to servers that follow the spec
|
|
210
|
+
* strictly (Brain MCP, most reference servers). Advanced features like
|
|
211
|
+
* GET-based server-push streams, session id headers, or OAuth redirect
|
|
212
|
+
* handshakes are intentionally out of scope — add when a real server
|
|
213
|
+
* requires them.
|
|
214
|
+
*/
|
|
215
|
+
export class HttpTransport extends EventEmitter implements MCPTransport {
|
|
216
|
+
readonly kind = 'http' as const
|
|
217
|
+
private stopped = false
|
|
218
|
+
|
|
219
|
+
constructor(private readonly opts: HttpTransportOptions) {
|
|
220
|
+
super()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async start(): Promise<void> {
|
|
224
|
+
// HTTP is stateless — nothing to open up-front. The first request
|
|
225
|
+
// forms the "connect" step from the protocol's point of view.
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
send(message: OutboundMessage): void {
|
|
229
|
+
if (this.stopped) {
|
|
230
|
+
throw new Error('HttpTransport: transport is stopped')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Fire-and-forward; errors are surfaced via the `error` event so
|
|
234
|
+
// MCPConnection can reject the pending promise.
|
|
235
|
+
void this.post(message).catch((err) => {
|
|
236
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)))
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async stop(): Promise<void> {
|
|
241
|
+
this.stopped = true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async post(message: OutboundMessage): Promise<void> {
|
|
245
|
+
const body = JSON.stringify(message)
|
|
246
|
+
const headers: Record<string, string> = {
|
|
247
|
+
'content-type': 'application/json',
|
|
248
|
+
accept: 'application/json, text/event-stream',
|
|
249
|
+
...(this.opts.headers ?? {}),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const res = await fetch(this.opts.url, { method: 'POST', headers, body })
|
|
253
|
+
|
|
254
|
+
// Notifications → server responds 202 Accepted with empty body.
|
|
255
|
+
if (res.status === 202) return
|
|
256
|
+
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
const text = await safeText(res)
|
|
259
|
+
throw new Error(`HTTP ${res.status} from MCP server: ${text.slice(0, 200)}`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const ct = (res.headers.get('content-type') ?? '').toLowerCase()
|
|
263
|
+
if (ct.includes('application/json')) {
|
|
264
|
+
const json = (await res.json()) as InboundMessage
|
|
265
|
+
this.emit('message', json)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (ct.includes('text/event-stream')) {
|
|
270
|
+
await this.consumeSse(res)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Unknown content-type — try JSON parse, fall back to log.
|
|
275
|
+
const text = await safeText(res)
|
|
276
|
+
try {
|
|
277
|
+
this.emit('message', JSON.parse(text) as InboundMessage)
|
|
278
|
+
} catch {
|
|
279
|
+
this.emit('log', `[non-json] ${text.slice(0, 200)}`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async consumeSse(res: Response): Promise<void> {
|
|
284
|
+
if (!res.body) return
|
|
285
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
286
|
+
const decoder = new TextDecoder()
|
|
287
|
+
let buffer = ''
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
while (true) {
|
|
291
|
+
const { done, value } = await reader.read()
|
|
292
|
+
if (done) break
|
|
293
|
+
buffer += decoder.decode(value, { stream: true })
|
|
294
|
+
const { events, rest } = parseSseEvents(buffer)
|
|
295
|
+
buffer = rest
|
|
296
|
+
for (const evt of events) {
|
|
297
|
+
if (evt.data.length === 0) continue
|
|
298
|
+
try {
|
|
299
|
+
this.emit('message', JSON.parse(evt.data) as InboundMessage)
|
|
300
|
+
} catch {
|
|
301
|
+
this.emit('log', `[sse-non-json] ${evt.data.slice(0, 200)}`)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
reader.releaseLock()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ════════════════════════════════════════════════════════════
|
|
312
|
+
// SSE transport (legacy)
|
|
313
|
+
// ════════════════════════════════════════════════════════════
|
|
314
|
+
|
|
315
|
+
export interface SseTransportOptions {
|
|
316
|
+
url: string
|
|
317
|
+
headers?: Record<string, string>
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Legacy SSE transport.
|
|
322
|
+
*
|
|
323
|
+
* Opens a long-lived `GET <url>` stream to receive server→client frames
|
|
324
|
+
* and uses `POST <postEndpoint>` for client→server frames. The
|
|
325
|
+
* `postEndpoint` is discovered from the first `endpoint` SSE event, per
|
|
326
|
+
* the MCP legacy SSE convention.
|
|
327
|
+
*
|
|
328
|
+
* If the server never emits an endpoint event, POSTs fall back to the
|
|
329
|
+
* original URL with `?endpoint=messages` appended — some implementations
|
|
330
|
+
* accept that.
|
|
331
|
+
*/
|
|
332
|
+
export class SseTransport extends EventEmitter implements MCPTransport {
|
|
333
|
+
readonly kind = 'sse' as const
|
|
334
|
+
private abort: AbortController | null = null
|
|
335
|
+
private postEndpoint: string | null = null
|
|
336
|
+
private endpointReady?: Promise<void>
|
|
337
|
+
private endpointResolve?: () => void
|
|
338
|
+
|
|
339
|
+
constructor(private readonly opts: SseTransportOptions) {
|
|
340
|
+
super()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async start(): Promise<void> {
|
|
344
|
+
this.abort = new AbortController()
|
|
345
|
+
this.endpointReady = new Promise<void>((r) => {
|
|
346
|
+
this.endpointResolve = r
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const headers: Record<string, string> = {
|
|
350
|
+
accept: 'text/event-stream',
|
|
351
|
+
...(this.opts.headers ?? {}),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
void fetch(this.opts.url, { headers, signal: this.abort.signal })
|
|
355
|
+
.then(async (res) => {
|
|
356
|
+
if (!res.ok) {
|
|
357
|
+
throw new Error(`HTTP ${res.status} opening SSE stream`)
|
|
358
|
+
}
|
|
359
|
+
await this.consume(res)
|
|
360
|
+
})
|
|
361
|
+
.catch((err) => {
|
|
362
|
+
if ((err as Error).name === 'AbortError') return
|
|
363
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)))
|
|
364
|
+
this.emit('exit', { code: null, signal: null })
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
send(message: OutboundMessage): void {
|
|
369
|
+
void this.postOnce(message).catch((err) => {
|
|
370
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)))
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async stop(): Promise<void> {
|
|
375
|
+
this.abort?.abort()
|
|
376
|
+
this.abort = null
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async postOnce(message: OutboundMessage): Promise<void> {
|
|
380
|
+
// Wait for the server to announce its POST endpoint (or timeout).
|
|
381
|
+
if (this.endpointReady) {
|
|
382
|
+
await Promise.race([
|
|
383
|
+
this.endpointReady,
|
|
384
|
+
new Promise<void>((r) => setTimeout(r, 2000)),
|
|
385
|
+
])
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const endpoint = this.postEndpoint ?? new URL('?endpoint=messages', this.opts.url).toString()
|
|
389
|
+
const headers: Record<string, string> = {
|
|
390
|
+
'content-type': 'application/json',
|
|
391
|
+
...(this.opts.headers ?? {}),
|
|
392
|
+
}
|
|
393
|
+
const res = await fetch(endpoint, {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
headers,
|
|
396
|
+
body: JSON.stringify(message),
|
|
397
|
+
})
|
|
398
|
+
if (!res.ok && res.status !== 202) {
|
|
399
|
+
const text = await safeText(res)
|
|
400
|
+
throw new Error(`HTTP ${res.status} posting to SSE endpoint: ${text.slice(0, 200)}`)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async consume(res: Response): Promise<void> {
|
|
405
|
+
if (!res.body) return
|
|
406
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
407
|
+
const decoder = new TextDecoder()
|
|
408
|
+
let buffer = ''
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
while (true) {
|
|
412
|
+
const { done, value } = await reader.read()
|
|
413
|
+
if (done) break
|
|
414
|
+
buffer += decoder.decode(value, { stream: true })
|
|
415
|
+
const { events, rest } = parseSseEvents(buffer)
|
|
416
|
+
buffer = rest
|
|
417
|
+
for (const evt of events) {
|
|
418
|
+
if (evt.event === 'endpoint') {
|
|
419
|
+
// Server announced its POST endpoint (absolute or relative).
|
|
420
|
+
const url = evt.data.trim()
|
|
421
|
+
this.postEndpoint = url.startsWith('http')
|
|
422
|
+
? url
|
|
423
|
+
: new URL(url, this.opts.url).toString()
|
|
424
|
+
this.endpointResolve?.()
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
if (evt.data.length === 0) continue
|
|
428
|
+
try {
|
|
429
|
+
this.emit('message', JSON.parse(evt.data) as InboundMessage)
|
|
430
|
+
} catch {
|
|
431
|
+
this.emit('log', `[sse-non-json] ${evt.data.slice(0, 200)}`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} finally {
|
|
436
|
+
reader.releaseLock()
|
|
437
|
+
this.emit('exit', { code: 0, signal: null })
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ════════════════════════════════════════════════════════════
|
|
443
|
+
// Helpers
|
|
444
|
+
// ════════════════════════════════════════════════════════════
|
|
445
|
+
|
|
446
|
+
interface SseEvent {
|
|
447
|
+
event?: string
|
|
448
|
+
data: string
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Parse SSE events from a buffer string. Returns emitted events and
|
|
453
|
+
* the unparsed tail (may be empty).
|
|
454
|
+
*/
|
|
455
|
+
function parseSseEvents(buffer: string): { events: SseEvent[]; rest: string } {
|
|
456
|
+
const events: SseEvent[] = []
|
|
457
|
+
const parts = buffer.split(/\r?\n\r?\n/)
|
|
458
|
+
const rest = parts.pop() ?? ''
|
|
459
|
+
|
|
460
|
+
for (const raw of parts) {
|
|
461
|
+
if (!raw.trim()) continue
|
|
462
|
+
const lines = raw.split(/\r?\n/)
|
|
463
|
+
let event: string | undefined
|
|
464
|
+
const dataLines: string[] = []
|
|
465
|
+
for (const line of lines) {
|
|
466
|
+
if (line.startsWith(':')) continue // SSE comment
|
|
467
|
+
const colon = line.indexOf(':')
|
|
468
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
469
|
+
const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
|
|
470
|
+
if (field === 'event') event = value
|
|
471
|
+
else if (field === 'data') dataLines.push(value)
|
|
472
|
+
}
|
|
473
|
+
events.push({ event, data: dataLines.join('\n') })
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return { events, rest }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function safeText(res: Response): Promise<string> {
|
|
480
|
+
try {
|
|
481
|
+
return await res.text()
|
|
482
|
+
} catch {
|
|
483
|
+
return ''
|
|
484
|
+
}
|
|
485
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -115,9 +115,17 @@ export interface ToolCallResult {
|
|
|
115
115
|
// MCP Connection config
|
|
116
116
|
// ════════════════════════════════════════════════════════════
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Transport kinds supported by MCPConnection.
|
|
120
|
+
* - stdio: local subprocess, newline-delimited JSON-RPC over stdin/stdout.
|
|
121
|
+
* - http : streamable-http (JSON-RPC POSTs returning text/event-stream or JSON).
|
|
122
|
+
* - sse : legacy Server-Sent Events transport.
|
|
123
|
+
*/
|
|
124
|
+
export type MCPTransportKind = 'stdio' | 'http' | 'sse'
|
|
125
|
+
|
|
126
|
+
/** stdio-specific config. */
|
|
127
|
+
export interface MCPStdioConfig {
|
|
128
|
+
transport: 'stdio'
|
|
121
129
|
/** Command to spawn the MCP server */
|
|
122
130
|
command: string
|
|
123
131
|
/** Command arguments */
|
|
@@ -126,11 +134,50 @@ export interface MCPServerConfig {
|
|
|
126
134
|
cwd?: string
|
|
127
135
|
/** Environment variables for the subprocess */
|
|
128
136
|
env?: Record<string, string>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** HTTP (streamable-http) specific config. */
|
|
140
|
+
export interface MCPHttpConfig {
|
|
141
|
+
transport: 'http'
|
|
142
|
+
/** MCP endpoint URL */
|
|
143
|
+
url: string
|
|
144
|
+
/** Extra headers to attach to every POST */
|
|
145
|
+
headers?: Record<string, string>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Server-Sent Events (legacy) specific config. */
|
|
149
|
+
export interface MCPSseConfig {
|
|
150
|
+
transport: 'sse'
|
|
151
|
+
/** MCP endpoint URL (SSE) */
|
|
152
|
+
url: string
|
|
153
|
+
/** Extra headers */
|
|
154
|
+
headers?: Record<string, string>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Transport discriminated union — exactly one variant per connection.
|
|
159
|
+
*/
|
|
160
|
+
export type MCPTransportConfig = MCPStdioConfig | MCPHttpConfig | MCPSseConfig
|
|
161
|
+
|
|
162
|
+
export interface MCPServerConfig {
|
|
163
|
+
/** Unique identifier for this connection */
|
|
164
|
+
id: string
|
|
165
|
+
/** Transport — if omitted, falls back to stdio using command/args. */
|
|
166
|
+
transport?: MCPTransportConfig
|
|
167
|
+
/** Legacy stdio shorthand — command for the subprocess.
|
|
168
|
+
* Ignored when `transport` is set. */
|
|
169
|
+
command?: string
|
|
170
|
+
/** Legacy stdio shorthand — args for the subprocess. */
|
|
171
|
+
args?: string[]
|
|
172
|
+
/** Working directory for the subprocess (stdio only) */
|
|
173
|
+
cwd?: string
|
|
174
|
+
/** Environment variables for the subprocess (stdio only) */
|
|
175
|
+
env?: Record<string, string>
|
|
129
176
|
/** Role: 'memory' creates a MemoryProvider adapter, 'tools' exposes tools to agent */
|
|
130
177
|
role?: 'memory' | 'tools' | 'both'
|
|
131
178
|
/** Connection timeout in ms. Default: 10000 */
|
|
132
179
|
timeoutMs?: number
|
|
133
|
-
/** Auto-restart on crash. Default: true */
|
|
180
|
+
/** Auto-restart on crash. Default: true (stdio only) */
|
|
134
181
|
autoRestart?: boolean
|
|
135
182
|
}
|
|
136
183
|
|