@onmars/lunar-mcp 0.7.0 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmars/lunar-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.7.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-engram', version: '0.1.0' },
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-engram')
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-engram')
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-engram', version: '0.1.0' },
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 Engram
13
+ * // Connect to any MCP server (stdio)
14
14
  * await registry.connect({
15
- * id: 'engram',
16
- * command: 'engram mcp',
17
- * role: 'memory',
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('mem_search', { query: 'auth middleware' })
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,
@@ -1,20 +1,25 @@
1
1
  /**
2
- * MCPConnection — Single connection to an MCP server via stdio
2
+ * MCPConnection — Single connection to an MCP server
3
3
  *
4
4
  * Manages:
5
- * - Subprocess lifecycle (spawn, restart, shutdown)
6
- * - JSON-RPC 2.0 protocol over stdin/stdout
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
- * Each MCPConnection wraps exactly one subprocess running an MCP server.
13
- * The MCPRegistry manages multiple connections.
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 process: ChildProcess | null = null
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
- * Spawn the MCP server subprocess and perform the MCP handshake.
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
- // 1. Spawn subprocess
80
- const [cmd, ...defaultArgs] = this.config.command.split(' ')
81
- const args = [...defaultArgs, ...(this.config.args || [])]
82
-
83
- try {
84
- this.process = spawn(cmd, args, {
85
- stdio: ['pipe', 'pipe', 'pipe'],
86
- cwd: this.config.cwd,
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(new Error(`MCP server exited (code=${code}, signal=${signal})`))
98
- this.emit('exit', { code, signal })
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
- // stderr → log (MCP servers may emit logs on stderr)
108
- this.process.stderr?.on('data', (chunk: Buffer) => {
109
- this.emit('log', chunk.toString())
110
- })
146
+ await this.transport.start()
111
147
 
112
- // stdout JSON-RPC message parsing
113
- this.process.stdout?.on('data', (chunk: Buffer) => {
114
- this.buffer += chunk.toString()
115
- this.processBuffer()
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
- // 2. MCP handshake: initialize
119
- const initResult = (await this.request(
120
- 'initialize',
121
- {
122
- protocolVersion: '2025-11-25',
123
- capabilities: {},
124
- clientInfo: {
125
- name: 'lunar',
126
- version: '0.2.0',
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
- this.config.timeoutMs || 10000,
130
- )) as InitializeResult
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
- // 3. Send initialized notification
182
+ // Send initialized notification
136
183
  this.notify('notifications/initialized')
137
184
 
138
- // 4. Discover tools
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.kill()
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 subprocess stdin.
219
- * MCP uses newline-delimited JSON over stdio.
266
+ * Write a JSON-RPC message to the underlying transport.
220
267
  */
221
- private send(message: JsonRpcRequest | JsonRpcNotification): void {
222
- if (!this.process?.stdin?.writable) {
223
- throw new Error(`MCP connection "${this.id}" stdin not writable`)
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: JsonRpcResponse | JsonRpcNotification): void {
278
+ private handleMessage(message: InboundMessage): void {
256
279
  // Response to a pending request
257
280
  if ('id' in message && message.id != null) {
258
- const pending = this.pending.get(message.id)
281
+ const response = message as JsonRpcResponse
282
+ const pending = this.pending.get(response.id)
259
283
  if (pending) {
260
- this.pending.delete(message.id)
284
+ this.pending.delete(response.id)
261
285
  clearTimeout(pending.timer)
262
286
 
263
- if (message.error) {
264
- pending.reject(new Error(`MCP error [${message.error.code}]: ${message.error.message}`))
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(message.result)
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: message.method,
276
- params: message.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: close stdin, wait, SIGTERM, SIGKILL.
320
+ * Graceful shutdown.
294
321
  */
295
322
  async disconnect(): Promise<void> {
296
- if (!this.process) return
297
-
298
- return new Promise<void>((resolve) => {
299
- const proc = this.process!
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 kill the subprocess immediately.
330
+ * Force close the transport immediately (stdio → SIGKILL).
334
331
  */
335
332
  kill(): void {
336
- if (this.process) {
337
- this.process.kill('SIGKILL')
338
- this.process = null
339
- this._state = 'disconnected'
340
- this._tools = []
341
- this.rejectAllPending(new Error('Connection killed'))
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
  }
@@ -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
- export interface MCPServerConfig {
119
- /** Unique identifier for this connection */
120
- id: string
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