@onmars/lunar-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 onMars Tech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @onmars/lunar-mcp
2
+
3
+ MCP (Model Context Protocol) client for [Lunar](https://github.com/onmars-tech/lunar).
4
+
5
+ This package is used internally by `@onmars/lunar-cli`. Install the CLI instead:
6
+
7
+ ```bash
8
+ bun install -g @onmars/lunar-cli
9
+ ```
10
+
11
+ ## License
12
+
13
+ MIT — [onMars Tech](https://github.com/onmars-tech)
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@onmars/lunar-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": ["src/", "LICENSE"],
11
+ "dependencies": {
12
+ "@onmars/lunar-core": "0.1.0"
13
+ },
14
+ "description": "MCP (Model Context Protocol) client for Lunar",
15
+ "author": "onMars Tech",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/onmars-tech/lunar",
20
+ "directory": "packages/mcp"
21
+ },
22
+ "homepage": "https://github.com/onmars-tech/lunar",
23
+ "bugs": "https://github.com/onmars-tech/lunar/issues",
24
+ "keywords": ["lunar", "mcp", "model-context-protocol", "bun"],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "bun": ">=1.2"
30
+ }
31
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * MCPConnection + MCPRegistry test suite
3
+ *
4
+ * Mocks node:child_process.spawn to avoid spawning real subprocesses.
5
+ * The mock process implements the MCP protocol in-memory:
6
+ * stdin.write() → parse JSON-RPC → emit response on stdout
7
+ *
8
+ * This makes tests fast, deterministic, and environment-independent.
9
+ */
10
+
11
+ import { afterEach, describe, expect, mock, test } from 'bun:test'
12
+ import { EventEmitter } from 'node:events'
13
+
14
+ // ── In-memory MCP protocol simulator ─────────────────────────────────────────
15
+
16
+ function createMockProcess() {
17
+ const stdout = new EventEmitter()
18
+ const stderr = new EventEmitter()
19
+ const exitListeners: Array<(code: number | null, signal: string | null) => void> = []
20
+ let exited = false
21
+
22
+ function emitExit(code: number | null, signal: string | null) {
23
+ if (!exited) {
24
+ exited = true
25
+ for (const fn of exitListeners) fn(code, signal)
26
+ }
27
+ }
28
+
29
+ const stdin = {
30
+ writable: true,
31
+ write(data: string | Buffer): boolean {
32
+ const line = (typeof data === 'string' ? data : data.toString()).trim()
33
+ if (!line) return true
34
+
35
+ let msg: Record<string, unknown>
36
+ try {
37
+ msg = JSON.parse(line)
38
+ } catch {
39
+ return true
40
+ }
41
+
42
+ // Notifications have no id — no response needed
43
+ if (!('id' in msg)) return true
44
+
45
+ const id = msg.id
46
+ const method = msg.method as string
47
+ const params = msg.params as Record<string, unknown> | undefined
48
+
49
+ let result: unknown
50
+ let error: { code: number; message: string } | undefined
51
+
52
+ if (method === 'initialize') {
53
+ result = {
54
+ protocolVersion: '2025-11-25',
55
+ capabilities: { tools: { listChanged: false } },
56
+ serverInfo: { name: 'mock-engram', version: '0.1.0' },
57
+ }
58
+ } else if (method === 'tools/list') {
59
+ result = {
60
+ tools: [
61
+ {
62
+ name: 'mem_search',
63
+ description: 'Search persistent memory',
64
+ inputSchema: {
65
+ type: 'object',
66
+ properties: { query: { type: 'string' }, limit: { type: 'number' } },
67
+ required: ['query'],
68
+ },
69
+ },
70
+ {
71
+ name: 'mem_save',
72
+ description: 'Save a memory observation',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: { title: { type: 'string' }, content: { type: 'string' } },
76
+ required: ['title', 'content'],
77
+ },
78
+ },
79
+ {
80
+ name: 'mem_context',
81
+ description: 'Get recent context',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: { project: { type: 'string' } },
85
+ },
86
+ },
87
+ ],
88
+ }
89
+ } else if (method === 'tools/call') {
90
+ const name = (params as any)?.name as string
91
+ if (name === 'mem_search') {
92
+ result = {
93
+ content: [
94
+ {
95
+ type: 'text',
96
+ text: JSON.stringify([
97
+ { id: 1, title: 'Auth middleware', content: 'JWT-based auth', score: 0.9 },
98
+ { id: 2, title: 'Database schema', content: 'PostgreSQL setup', score: 0.7 },
99
+ ]),
100
+ },
101
+ ],
102
+ }
103
+ } else if (name === 'mem_save') {
104
+ result = {
105
+ content: [{ type: 'text', text: JSON.stringify({ id: 42, status: 'saved' }) }],
106
+ }
107
+ } else if (name === 'mem_context') {
108
+ result = {
109
+ content: [{ type: 'text', text: 'Session 2026-02-28: Worked on MCP integration' }],
110
+ }
111
+ } else {
112
+ error = { code: -32601, message: `Unknown tool: ${name}` }
113
+ }
114
+ } else if (method === 'ping') {
115
+ result = {}
116
+ } else {
117
+ error = { code: -32601, message: `Method not found: ${method}` }
118
+ }
119
+
120
+ // Respond asynchronously (realistic: network/process I/O is async)
121
+ setImmediate(() => {
122
+ const resp = error
123
+ ? JSON.stringify({ jsonrpc: '2.0', id, error })
124
+ : JSON.stringify({ jsonrpc: '2.0', id, result })
125
+ stdout.emit('data', Buffer.from(resp + '\n'))
126
+ })
127
+
128
+ return true
129
+ },
130
+ end() {
131
+ stdin.writable = false
132
+ setImmediate(() => emitExit(0, null))
133
+ },
134
+ }
135
+
136
+ return {
137
+ stdin,
138
+ stdout,
139
+ stderr,
140
+ pid: 12345,
141
+ on(event: string, listener: (...args: unknown[]) => void) {
142
+ if (event === 'exit') exitListeners.push(listener as any)
143
+ return this
144
+ },
145
+ kill(signal = 'SIGKILL') {
146
+ stdin.writable = false
147
+ setImmediate(() => emitExit(null, signal))
148
+ return true
149
+ },
150
+ }
151
+ }
152
+
153
+ // Mock spawn before any imports of modules that use it.
154
+ // Bun hoists mock.module() calls above static imports (same as jest.mock hoisting).
155
+ mock.module('node:child_process', () => ({
156
+ spawn: mock((_cmd: string, _args: string[], _opts: unknown) => createMockProcess()),
157
+ }))
158
+
159
+ import { MCPConnection } from '../lib/connection'
160
+ import { MCPRegistry } from '../lib/registry'
161
+
162
+ function mockConfig(id = 'test', overrides: Record<string, unknown> = {}) {
163
+ return {
164
+ id,
165
+ command: 'mock-mcp-server',
166
+ timeoutMs: 5000,
167
+ autoRestart: false,
168
+ ...overrides,
169
+ }
170
+ }
171
+
172
+ // ═══════════════════════════════════════════════════════════
173
+ // MCPConnection
174
+ // ═══════════════════════════════════════════════════════════
175
+
176
+ describe('MCPConnection', () => {
177
+ let conn: MCPConnection | null = null
178
+
179
+ afterEach(async () => {
180
+ if (conn) {
181
+ conn.kill()
182
+ conn = null
183
+ }
184
+ })
185
+
186
+ test('connects and performs MCP handshake', async () => {
187
+ conn = new MCPConnection(mockConfig())
188
+ await conn.connect()
189
+ expect(conn.state).toBe('ready')
190
+ expect(conn.serverInfo?.name).toBe('mock-engram')
191
+ expect(conn.serverInfo?.version).toBe('0.1.0')
192
+ })
193
+
194
+ test('discovers tools after handshake', async () => {
195
+ conn = new MCPConnection(mockConfig())
196
+ await conn.connect()
197
+ expect(conn.tools.length).toBe(3)
198
+ expect(conn.tools.map((t) => t.name)).toEqual(['mem_search', 'mem_save', 'mem_context'])
199
+ })
200
+
201
+ test('hasTool returns true for existing tools', async () => {
202
+ conn = new MCPConnection(mockConfig())
203
+ await conn.connect()
204
+ expect(conn.hasTool('mem_search')).toBe(true)
205
+ expect(conn.hasTool('nonexistent')).toBe(false)
206
+ })
207
+
208
+ test('callTool executes a tool and returns result', async () => {
209
+ conn = new MCPConnection(mockConfig())
210
+ await conn.connect()
211
+ const result = await conn.callTool('mem_search', { query: 'auth' })
212
+ expect(result.content).toBeDefined()
213
+ expect(result.content[0].type).toBe('text')
214
+ const parsed = JSON.parse(result.content[0].text!)
215
+ expect(parsed).toHaveLength(2)
216
+ expect(parsed[0].title).toBe('Auth middleware')
217
+ })
218
+
219
+ test('callTool for mem_save returns saved status', async () => {
220
+ conn = new MCPConnection(mockConfig())
221
+ await conn.connect()
222
+ const result = await conn.callTool('mem_save', { title: 'Test', content: 'Content' })
223
+ const parsed = JSON.parse(result.content[0].text!)
224
+ expect(parsed.status).toBe('saved')
225
+ expect(parsed.id).toBe(42)
226
+ })
227
+
228
+ test('callTool throws when not connected', async () => {
229
+ conn = new MCPConnection(mockConfig())
230
+ // Don't call connect() — should reject immediately
231
+ await expect(conn.callTool('mem_search', { query: 'test' })).rejects.toThrow('not ready')
232
+ })
233
+
234
+ test('disconnect gracefully shuts down', async () => {
235
+ conn = new MCPConnection(mockConfig())
236
+ await conn.connect()
237
+ expect(conn.state).toBe('ready')
238
+ await conn.disconnect()
239
+ expect(conn.state).toBe('disconnected')
240
+ conn = null
241
+ })
242
+
243
+ test('tools returns a copy (immutable)', async () => {
244
+ conn = new MCPConnection(mockConfig())
245
+ await conn.connect()
246
+ const tools1 = conn.tools
247
+ const tools2 = conn.tools
248
+ expect(tools1).toEqual(tools2)
249
+ expect(tools1).not.toBe(tools2)
250
+ })
251
+ })
252
+
253
+ // ═══════════════════════════════════════════════════════════
254
+ // MCPRegistry
255
+ // ═══════════════════════════════════════════════════════════
256
+
257
+ describe('MCPRegistry', () => {
258
+ let registry: MCPRegistry | null = null
259
+
260
+ afterEach(async () => {
261
+ if (registry) {
262
+ await registry.disconnectAll()
263
+ registry = null
264
+ }
265
+ })
266
+
267
+ test('connects and registers a server', async () => {
268
+ registry = new MCPRegistry()
269
+ await registry.connect(mockConfig('server1'))
270
+ const status = registry.status()
271
+ expect(status).toHaveLength(1)
272
+ expect(status[0].id).toBe('server1')
273
+ expect(status[0].state).toBe('ready')
274
+ expect(status[0].serverName).toBe('mock-engram')
275
+ expect(status[0].toolCount).toBe(3)
276
+ })
277
+
278
+ test('lists tools from all connected servers', async () => {
279
+ registry = new MCPRegistry()
280
+ await registry.connect(mockConfig('server1'))
281
+ const tools = registry.listTools()
282
+ expect(tools.length).toBe(3)
283
+ expect(tools[0].serverId).toBe('server1')
284
+ })
285
+
286
+ test('connects multiple servers simultaneously', async () => {
287
+ registry = new MCPRegistry()
288
+ await registry.connect(mockConfig('server1'))
289
+ await registry.connect(mockConfig('server2'))
290
+ const status = registry.status()
291
+ expect(status).toHaveLength(2)
292
+ const tools = registry.listTools()
293
+ expect(tools.length).toBe(6)
294
+ })
295
+
296
+ test('routes callTool to the correct server', async () => {
297
+ registry = new MCPRegistry()
298
+ await registry.connect(mockConfig('server1'))
299
+ const result = await registry.callTool('mem_search', { query: 'test' })
300
+ expect(result).toBeDefined()
301
+ })
302
+
303
+ test('callTool throws for unknown tool', async () => {
304
+ registry = new MCPRegistry()
305
+ await registry.connect(mockConfig('server1'))
306
+ await expect(registry.callTool('nonexistent_tool')).rejects.toThrow('not found')
307
+ })
308
+
309
+ test('throws on duplicate connection id', async () => {
310
+ registry = new MCPRegistry()
311
+ await registry.connect(mockConfig('server1'))
312
+ await expect(registry.connect(mockConfig('server1'))).rejects.toThrow('already exists')
313
+ })
314
+
315
+ test('disconnect removes server and its tools', async () => {
316
+ registry = new MCPRegistry()
317
+ await registry.connect(mockConfig('server1'))
318
+ expect(registry.listTools().length).toBe(3)
319
+ await registry.disconnect('server1')
320
+ expect(registry.status()).toHaveLength(0)
321
+ expect(registry.listTools().length).toBe(0)
322
+ })
323
+
324
+ test('getByRole filters connections', async () => {
325
+ registry = new MCPRegistry()
326
+ await registry.connect(mockConfig('mem1', { role: 'memory' }))
327
+ await registry.connect(mockConfig('tool1', { role: 'tools' }))
328
+ const memoryConns = registry.getByRole('memory')
329
+ expect(memoryConns).toHaveLength(1)
330
+ expect(memoryConns[0].id).toBe('mem1')
331
+ const toolConns = registry.getByRole('tools')
332
+ expect(toolConns).toHaveLength(1)
333
+ expect(toolConns[0].id).toBe('tool1')
334
+ })
335
+
336
+ test('disconnectAll shuts down everything', async () => {
337
+ registry = new MCPRegistry()
338
+ await registry.connect(mockConfig('server1'))
339
+ await registry.connect(mockConfig('server2'))
340
+ await registry.disconnectAll()
341
+ expect(registry.status()).toHaveLength(0)
342
+ registry = null
343
+ })
344
+
345
+ test('get returns connection by id', async () => {
346
+ registry = new MCPRegistry()
347
+ await registry.connect(mockConfig('server1'))
348
+ expect(registry.get('server1')).toBeDefined()
349
+ expect(registry.get('nonexistent')).toBeUndefined()
350
+ })
351
+ })
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mock MCP server for testing.
4
+ * Implements the minimum MCP protocol over stdio.
5
+ * Responds to: initialize, tools/list, tools/call
6
+ */
7
+
8
+ import * as readline from 'node:readline'
9
+
10
+ const TOOLS = [
11
+ {
12
+ name: 'mem_search',
13
+ description: 'Search persistent memory',
14
+ inputSchema: {
15
+ type: 'object' as const,
16
+ properties: { query: { type: 'string' }, limit: { type: 'number' } },
17
+ required: ['query'],
18
+ },
19
+ },
20
+ {
21
+ name: 'mem_save',
22
+ description: 'Save a memory observation',
23
+ inputSchema: {
24
+ type: 'object' as const,
25
+ properties: {
26
+ title: { type: 'string' },
27
+ content: { type: 'string' },
28
+ type: { type: 'string' },
29
+ },
30
+ required: ['title', 'content'],
31
+ },
32
+ },
33
+ {
34
+ name: 'mem_context',
35
+ description: 'Get recent context',
36
+ inputSchema: {
37
+ type: 'object' as const,
38
+ properties: { project: { type: 'string' } },
39
+ },
40
+ },
41
+ ]
42
+
43
+ function respond(id: number | string, result: unknown) {
44
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, result })
45
+ process.stdout.write(msg + '\n')
46
+ }
47
+
48
+ function respondError(id: number | string, code: number, message: string) {
49
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })
50
+ process.stdout.write(msg + '\n')
51
+ }
52
+
53
+ const rl = readline.createInterface({ input: process.stdin })
54
+
55
+ rl.on('line', (line) => {
56
+ try {
57
+ const msg = JSON.parse(line.trim())
58
+
59
+ if (msg.method === 'initialize') {
60
+ respond(msg.id, {
61
+ protocolVersion: '2025-11-25',
62
+ capabilities: { tools: { listChanged: false } },
63
+ serverInfo: { name: 'mock-engram', version: '0.1.0' },
64
+ })
65
+ return
66
+ }
67
+
68
+ if (msg.method === 'notifications/initialized') {
69
+ // Notification — no response needed
70
+ return
71
+ }
72
+
73
+ if (msg.method === 'tools/list') {
74
+ respond(msg.id, { tools: TOOLS })
75
+ return
76
+ }
77
+
78
+ if (msg.method === 'tools/call') {
79
+ const { name, arguments: args } = msg.params
80
+
81
+ if (name === 'mem_search') {
82
+ respond(msg.id, {
83
+ content: [
84
+ {
85
+ type: 'text',
86
+ text: JSON.stringify([
87
+ { id: 1, title: 'Auth middleware', content: 'JWT-based auth', score: 0.9 },
88
+ { id: 2, title: 'Database schema', content: 'PostgreSQL setup', score: 0.7 },
89
+ ]),
90
+ },
91
+ ],
92
+ })
93
+ return
94
+ }
95
+
96
+ if (name === 'mem_save') {
97
+ respond(msg.id, {
98
+ content: [{ type: 'text', text: JSON.stringify({ id: 42, status: 'saved' }) }],
99
+ })
100
+ return
101
+ }
102
+
103
+ if (name === 'mem_context') {
104
+ respond(msg.id, {
105
+ content: [{ type: 'text', text: 'Session 2026-02-28: Worked on MCP integration' }],
106
+ })
107
+ return
108
+ }
109
+
110
+ if (name === 'mem_session_start') {
111
+ respond(msg.id, {
112
+ content: [{ type: 'text', text: JSON.stringify({ status: 'started', id: args.id }) }],
113
+ })
114
+ return
115
+ }
116
+
117
+ if (name === 'mem_session_end') {
118
+ respond(msg.id, {
119
+ content: [{ type: 'text', text: JSON.stringify({ status: 'ended' }) }],
120
+ })
121
+ return
122
+ }
123
+
124
+ if (name === 'mem_session_summary') {
125
+ respond(msg.id, {
126
+ content: [{ type: 'text', text: JSON.stringify({ status: 'saved' }) }],
127
+ })
128
+ return
129
+ }
130
+
131
+ respondError(msg.id, -32601, `Unknown tool: ${name}`)
132
+ return
133
+ }
134
+
135
+ if (msg.method === 'ping') {
136
+ respond(msg.id, {})
137
+ return
138
+ }
139
+
140
+ // Unknown method
141
+ if (msg.id) {
142
+ respondError(msg.id, -32601, `Method not found: ${msg.method}`)
143
+ }
144
+ } catch {
145
+ // Ignore parse errors
146
+ }
147
+ })
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @onmars/lunar-mcp — MCP client for the Lunar framework
3
+ *
4
+ * Manages multiple MCP server connections via stdio transport.
5
+ * Provides tool discovery, routing, and memory provider adapters.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { MCPRegistry } from '@onmars/lunar-mcp'
10
+ *
11
+ * const registry = new MCPRegistry()
12
+ *
13
+ * // Connect to Engram
14
+ * await registry.connect({
15
+ * id: 'engram',
16
+ * command: 'engram mcp',
17
+ * role: 'memory',
18
+ * })
19
+ *
20
+ * // List all tools
21
+ * const tools = registry.listTools()
22
+ *
23
+ * // Call a tool (auto-routes to correct server)
24
+ * await registry.callTool('mem_search', { query: 'auth middleware' })
25
+ * ```
26
+ */
27
+
28
+ // Core
29
+ export { type ConnectionState, MCPConnection } from './lib/connection'
30
+ export { type MCPConnectionStatus, MCPRegistry } from './lib/registry'
31
+
32
+ // Types
33
+ export type {
34
+ ClientCapabilities,
35
+ ClientInfo,
36
+ InitializeParams,
37
+ InitializeResult,
38
+ JsonRpcError,
39
+ JsonRpcMessage,
40
+ JsonRpcNotification,
41
+ JsonRpcRequest,
42
+ JsonRpcResponse,
43
+ MCPServerConfig,
44
+ MCPTool,
45
+ ServerCapabilities,
46
+ ServerInfo,
47
+ ToolCallParams,
48
+ ToolCallResult,
49
+ ToolContent,
50
+ ToolsListResult,
51
+ } from './lib/types'
52
+
53
+ export { JSON_RPC_ERRORS } from './lib/types'
@@ -0,0 +1,344 @@
1
+ /**
2
+ * MCPConnection — Single connection to an MCP server via stdio
3
+ *
4
+ * Manages:
5
+ * - Subprocess lifecycle (spawn, restart, shutdown)
6
+ * - JSON-RPC 2.0 protocol over stdin/stdout
7
+ * - MCP handshake (initialize → initialized)
8
+ * - Tool discovery and invocation
9
+ * - Request/response correlation via message IDs
10
+ * - Newline-delimited JSON framing
11
+ *
12
+ * Each MCPConnection wraps exactly one subprocess running an MCP server.
13
+ * The MCPRegistry manages multiple connections.
14
+ */
15
+
16
+ import { type ChildProcess, spawn } from 'node:child_process'
17
+ import { EventEmitter } from 'node:events'
18
+ import type {
19
+ InitializeResult,
20
+ JsonRpcNotification,
21
+ JsonRpcRequest,
22
+ JsonRpcResponse,
23
+ MCPServerConfig,
24
+ MCPTool,
25
+ ServerCapabilities,
26
+ ServerInfo,
27
+ ToolCallResult,
28
+ } from './types'
29
+
30
+ export type ConnectionState = 'disconnected' | 'connecting' | 'ready' | 'error'
31
+
32
+ export class MCPConnection extends EventEmitter {
33
+ readonly id: string
34
+ private config: MCPServerConfig
35
+ private process: ChildProcess | null = null
36
+ private nextId = 1
37
+ private pending = new Map<
38
+ number | string,
39
+ {
40
+ resolve: (value: unknown) => void
41
+ reject: (error: Error) => void
42
+ timer: ReturnType<typeof setTimeout>
43
+ }
44
+ >()
45
+ private buffer = ''
46
+ private _state: ConnectionState = 'disconnected'
47
+ private _tools: MCPTool[] = []
48
+ private _serverInfo: ServerInfo | null = null
49
+ private _capabilities: ServerCapabilities | null = null
50
+
51
+ get state(): ConnectionState {
52
+ return this._state
53
+ }
54
+ get tools(): MCPTool[] {
55
+ return [...this._tools]
56
+ }
57
+ get serverInfo(): ServerInfo | null {
58
+ return this._serverInfo
59
+ }
60
+ get capabilities(): ServerCapabilities | null {
61
+ return this._capabilities
62
+ }
63
+
64
+ constructor(config: MCPServerConfig) {
65
+ super()
66
+ this.id = config.id
67
+ this.config = config
68
+ }
69
+
70
+ /**
71
+ * Spawn the MCP server subprocess and perform the MCP handshake.
72
+ * After this resolves, the connection is ready for tool calls.
73
+ */
74
+ async connect(): Promise<void> {
75
+ if (this._state === 'ready') return
76
+ this._state = 'connecting'
77
+
78
+ 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) => {
96
+ this._state = 'disconnected'
97
+ this.rejectAllPending(new Error(`MCP server exited (code=${code}, signal=${signal})`))
98
+ this.emit('exit', { code, signal })
99
+ })
100
+
101
+ this.process.on('error', (err) => {
102
+ this._state = 'error'
103
+ this.rejectAllPending(err)
104
+ this.emit('error', err)
105
+ })
106
+
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
+ })
111
+
112
+ // stdout → JSON-RPC message parsing
113
+ this.process.stdout?.on('data', (chunk: Buffer) => {
114
+ this.buffer += chunk.toString()
115
+ this.processBuffer()
116
+ })
117
+
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',
127
+ },
128
+ },
129
+ this.config.timeoutMs || 10000,
130
+ )) as InitializeResult
131
+
132
+ this._serverInfo = initResult.serverInfo
133
+ this._capabilities = initResult.capabilities
134
+
135
+ // 3. Send initialized notification
136
+ this.notify('notifications/initialized')
137
+
138
+ // 4. Discover tools
139
+ if (initResult.capabilities?.tools) {
140
+ const toolsResult = (await this.request('tools/list', {})) as { tools: MCPTool[] }
141
+ this._tools = toolsResult.tools || []
142
+ }
143
+
144
+ this._state = 'ready'
145
+ this.emit('ready', {
146
+ serverInfo: this._serverInfo,
147
+ tools: this._tools.length,
148
+ })
149
+ } catch (err) {
150
+ this._state = 'error'
151
+ this.kill()
152
+ throw err
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Call an MCP tool by name. Returns the tool result.
158
+ */
159
+ async callTool(name: string, args?: Record<string, unknown>): Promise<ToolCallResult> {
160
+ if (this._state !== 'ready') {
161
+ throw new Error(`MCP connection "${this.id}" is not ready (state: ${this._state})`)
162
+ }
163
+ return this.request('tools/call', {
164
+ name,
165
+ arguments: args || {},
166
+ }) as Promise<ToolCallResult>
167
+ }
168
+
169
+ /**
170
+ * Check if this connection has a specific tool.
171
+ */
172
+ hasTool(name: string): boolean {
173
+ return this._tools.some((t) => t.name === name)
174
+ }
175
+
176
+ /**
177
+ * Send a JSON-RPC request and wait for the response.
178
+ */
179
+ private request(
180
+ method: string,
181
+ params: Record<string, unknown>,
182
+ timeoutMs = 30000,
183
+ ): Promise<unknown> {
184
+ return new Promise((resolve, reject) => {
185
+ const id = this.nextId++
186
+
187
+ const timer = setTimeout(() => {
188
+ this.pending.delete(id)
189
+ reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`))
190
+ }, timeoutMs)
191
+
192
+ this.pending.set(id, { resolve, reject, timer })
193
+
194
+ const message: JsonRpcRequest = {
195
+ jsonrpc: '2.0',
196
+ id,
197
+ method,
198
+ params,
199
+ }
200
+
201
+ this.send(message)
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Send a JSON-RPC notification (no response expected).
207
+ */
208
+ private notify(method: string, params?: Record<string, unknown>): void {
209
+ const message: JsonRpcNotification = {
210
+ jsonrpc: '2.0',
211
+ method,
212
+ ...(params && { params }),
213
+ }
214
+ this.send(message)
215
+ }
216
+
217
+ /**
218
+ * Write a JSON-RPC message to the subprocess stdin.
219
+ * MCP uses newline-delimited JSON over stdio.
220
+ */
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
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Handle an incoming JSON-RPC message (response or server notification).
254
+ */
255
+ private handleMessage(message: JsonRpcResponse | JsonRpcNotification): void {
256
+ // Response to a pending request
257
+ if ('id' in message && message.id != null) {
258
+ const pending = this.pending.get(message.id)
259
+ if (pending) {
260
+ this.pending.delete(message.id)
261
+ clearTimeout(pending.timer)
262
+
263
+ if (message.error) {
264
+ pending.reject(new Error(`MCP error [${message.error.code}]: ${message.error.message}`))
265
+ } else {
266
+ pending.resolve(message.result)
267
+ }
268
+ }
269
+ return
270
+ }
271
+
272
+ // Server-initiated notification
273
+ if ('method' in message) {
274
+ this.emit('notification', {
275
+ method: message.method,
276
+ params: message.params,
277
+ })
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Reject all pending requests (used on disconnect/error).
283
+ */
284
+ private rejectAllPending(error: Error): void {
285
+ for (const [id, pending] of this.pending) {
286
+ clearTimeout(pending.timer)
287
+ pending.reject(error)
288
+ }
289
+ this.pending.clear()
290
+ }
291
+
292
+ /**
293
+ * Graceful shutdown: close stdin, wait, SIGTERM, SIGKILL.
294
+ */
295
+ 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
+ })
330
+ }
331
+
332
+ /**
333
+ * Force kill the subprocess immediately.
334
+ */
335
+ 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'))
342
+ }
343
+ }
344
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * MCPRegistry — Manages multiple MCP server connections
3
+ *
4
+ * Features:
5
+ * - Connect/disconnect multiple MCP servers
6
+ * - Aggregate tools from all connections
7
+ * - Route tool calls to the correct server
8
+ * - Filter connections by role (memory, tools, both)
9
+ * - Health check all connections
10
+ * - Graceful shutdown of all connections
11
+ */
12
+
13
+ import { EventEmitter } from 'node:events'
14
+ import { type ConnectionState, MCPConnection } from './connection'
15
+ import type { MCPServerConfig, MCPTool } from './types'
16
+
17
+ export interface MCPConnectionStatus {
18
+ id: string
19
+ state: ConnectionState
20
+ serverName?: string
21
+ serverVersion?: string
22
+ toolCount: number
23
+ role: string
24
+ }
25
+
26
+ export class MCPRegistry extends EventEmitter {
27
+ private connections = new Map<string, MCPConnection>()
28
+ private configs = new Map<string, MCPServerConfig>()
29
+ /** Maps tool name → connection id for routing */
30
+ private toolIndex = new Map<string, string>()
31
+
32
+ /**
33
+ * Connect to an MCP server. Spawns the subprocess and performs handshake.
34
+ *
35
+ * @throws if connection fails (timeout, crash, handshake error)
36
+ */
37
+ async connect(config: MCPServerConfig): Promise<MCPConnection> {
38
+ if (this.connections.has(config.id)) {
39
+ throw new Error(`MCP connection "${config.id}" already exists`)
40
+ }
41
+
42
+ const conn = new MCPConnection(config)
43
+ this.connections.set(config.id, conn)
44
+ this.configs.set(config.id, config)
45
+
46
+ // Forward events
47
+ conn.on('ready', (info) => this.emit('server:ready', { id: config.id, ...info }))
48
+ conn.on('exit', (info) => {
49
+ this.emit('server:exit', { id: config.id, ...info })
50
+ this.removeToolsForConnection(config.id)
51
+
52
+ // Auto-restart if configured
53
+ if (config.autoRestart !== false) {
54
+ this.emit('server:restarting', { id: config.id })
55
+ setTimeout(() => this.reconnect(config.id).catch(() => {}), 2000)
56
+ }
57
+ })
58
+ conn.on('error', (err) => this.emit('server:error', { id: config.id, error: err }))
59
+
60
+ await conn.connect()
61
+
62
+ // Index tools for routing
63
+ for (const tool of conn.tools) {
64
+ this.toolIndex.set(tool.name, config.id)
65
+ }
66
+
67
+ return conn
68
+ }
69
+
70
+ /**
71
+ * Disconnect and remove an MCP server.
72
+ */
73
+ async disconnect(id: string): Promise<void> {
74
+ const conn = this.connections.get(id)
75
+ if (!conn) return
76
+
77
+ this.removeToolsForConnection(id)
78
+ await conn.disconnect()
79
+ this.connections.delete(id)
80
+ this.configs.delete(id)
81
+ }
82
+
83
+ /**
84
+ * Reconnect a previously configured server.
85
+ */
86
+ private async reconnect(id: string): Promise<void> {
87
+ const config = this.configs.get(id)
88
+ if (!config) return
89
+
90
+ const existing = this.connections.get(id)
91
+ if (existing) {
92
+ existing.kill()
93
+ this.connections.delete(id)
94
+ }
95
+
96
+ const conn = new MCPConnection(config)
97
+ this.connections.set(id, conn)
98
+
99
+ conn.on('exit', (info) => {
100
+ this.emit('server:exit', { id, ...info })
101
+ this.removeToolsForConnection(id)
102
+ })
103
+
104
+ try {
105
+ await conn.connect()
106
+ for (const tool of conn.tools) {
107
+ this.toolIndex.set(tool.name, id)
108
+ }
109
+ this.emit('server:reconnected', { id })
110
+ } catch {
111
+ this.emit('server:reconnect-failed', { id })
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Get a specific connection by ID.
117
+ */
118
+ get(id: string): MCPConnection | undefined {
119
+ return this.connections.get(id)
120
+ }
121
+
122
+ /**
123
+ * List all tools from all connected servers.
124
+ * Each tool includes which server it belongs to.
125
+ */
126
+ listTools(): Array<MCPTool & { serverId: string }> {
127
+ const tools: Array<MCPTool & { serverId: string }> = []
128
+ for (const [id, conn] of this.connections) {
129
+ if (conn.state !== 'ready') continue
130
+ for (const tool of conn.tools) {
131
+ tools.push({ ...tool, serverId: id })
132
+ }
133
+ }
134
+ return tools
135
+ }
136
+
137
+ /**
138
+ * Call a tool. Automatically routes to the correct MCP server.
139
+ *
140
+ * @throws if the tool is not found in any connected server
141
+ */
142
+ async callTool(name: string, args?: Record<string, unknown>): Promise<unknown> {
143
+ const connId = this.toolIndex.get(name)
144
+ if (!connId) {
145
+ throw new Error(`MCP tool "${name}" not found in any connected server`)
146
+ }
147
+
148
+ const conn = this.connections.get(connId)
149
+ if (!conn || conn.state !== 'ready') {
150
+ throw new Error(`MCP server "${connId}" not ready for tool "${name}"`)
151
+ }
152
+
153
+ const result = await conn.callTool(name, args)
154
+
155
+ // Extract text content for convenience
156
+ if (result.isError) {
157
+ const errorText = result.content
158
+ .filter((c) => c.type === 'text')
159
+ .map((c) => c.text)
160
+ .join('\n')
161
+ throw new Error(`MCP tool "${name}" error: ${errorText}`)
162
+ }
163
+
164
+ return result
165
+ }
166
+
167
+ /**
168
+ * Get connections with a specific role (for adapter creation).
169
+ */
170
+ getByRole(role: 'memory' | 'tools' | 'both'): MCPConnection[] {
171
+ const result: MCPConnection[] = []
172
+ for (const [id, conn] of this.connections) {
173
+ const config = this.configs.get(id)
174
+ if (!config) continue
175
+ if (config.role === role || config.role === 'both' || role === 'both') {
176
+ result.push(conn)
177
+ }
178
+ }
179
+ return result
180
+ }
181
+
182
+ /**
183
+ * Status of all connections.
184
+ */
185
+ status(): MCPConnectionStatus[] {
186
+ return Array.from(this.connections.entries()).map(([id, conn]) => ({
187
+ id,
188
+ state: conn.state,
189
+ serverName: conn.serverInfo?.name,
190
+ serverVersion: conn.serverInfo?.version,
191
+ toolCount: conn.tools.length,
192
+ role: this.configs.get(id)?.role || 'tools',
193
+ }))
194
+ }
195
+
196
+ /**
197
+ * Gracefully disconnect all servers.
198
+ */
199
+ async disconnectAll(): Promise<void> {
200
+ const promises = Array.from(this.connections.keys()).map((id) => this.disconnect(id))
201
+ await Promise.allSettled(promises)
202
+ }
203
+
204
+ /**
205
+ * Remove tool index entries for a disconnected server.
206
+ */
207
+ private removeToolsForConnection(id: string): void {
208
+ for (const [toolName, connId] of this.toolIndex) {
209
+ if (connId === id) this.toolIndex.delete(toolName)
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * MCP Protocol types — JSON-RPC 2.0 + MCP extensions
3
+ *
4
+ * Based on: https://modelcontextprotocol.io/specification/2025-11-25
5
+ * Protocol version: 2025-11-25
6
+ */
7
+
8
+ // ════════════════════════════════════════════════════════════
9
+ // JSON-RPC 2.0 base types
10
+ // ════════════════════════════════════════════════════════════
11
+
12
+ export interface JsonRpcRequest {
13
+ jsonrpc: '2.0'
14
+ id: number | string
15
+ method: string
16
+ params?: Record<string, unknown>
17
+ }
18
+
19
+ export interface JsonRpcResponse {
20
+ jsonrpc: '2.0'
21
+ id: number | string
22
+ result?: unknown
23
+ error?: JsonRpcError
24
+ }
25
+
26
+ export interface JsonRpcNotification {
27
+ jsonrpc: '2.0'
28
+ method: string
29
+ params?: Record<string, unknown>
30
+ }
31
+
32
+ export interface JsonRpcError {
33
+ code: number
34
+ message: string
35
+ data?: unknown
36
+ }
37
+
38
+ export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
39
+
40
+ // ════════════════════════════════════════════════════════════
41
+ // MCP initialization
42
+ // ════════════════════════════════════════════════════════════
43
+
44
+ export interface ClientInfo {
45
+ name: string
46
+ version: string
47
+ }
48
+
49
+ export interface ServerInfo {
50
+ name: string
51
+ version: string
52
+ }
53
+
54
+ export interface ClientCapabilities {
55
+ roots?: { listChanged?: boolean }
56
+ sampling?: Record<string, unknown>
57
+ }
58
+
59
+ export interface ServerCapabilities {
60
+ tools?: { listChanged?: boolean }
61
+ resources?: { subscribe?: boolean; listChanged?: boolean }
62
+ prompts?: { listChanged?: boolean }
63
+ logging?: Record<string, unknown>
64
+ }
65
+
66
+ export interface InitializeParams {
67
+ protocolVersion: string
68
+ capabilities: ClientCapabilities
69
+ clientInfo: ClientInfo
70
+ }
71
+
72
+ export interface InitializeResult {
73
+ protocolVersion: string
74
+ capabilities: ServerCapabilities
75
+ serverInfo: ServerInfo
76
+ instructions?: string
77
+ }
78
+
79
+ // ════════════════════════════════════════════════════════════
80
+ // MCP Tools
81
+ // ════════════════════════════════════════════════════════════
82
+
83
+ export interface MCPTool {
84
+ name: string
85
+ description?: string
86
+ inputSchema: {
87
+ type: 'object'
88
+ properties?: Record<string, unknown>
89
+ required?: string[]
90
+ }
91
+ }
92
+
93
+ export interface ToolsListResult {
94
+ tools: MCPTool[]
95
+ }
96
+
97
+ export interface ToolCallParams {
98
+ name: string
99
+ arguments?: Record<string, unknown>
100
+ }
101
+
102
+ export interface ToolContent {
103
+ type: 'text' | 'image' | 'resource'
104
+ text?: string
105
+ data?: string
106
+ mimeType?: string
107
+ }
108
+
109
+ export interface ToolCallResult {
110
+ content: ToolContent[]
111
+ isError?: boolean
112
+ }
113
+
114
+ // ════════════════════════════════════════════════════════════
115
+ // MCP Connection config
116
+ // ════════════════════════════════════════════════════════════
117
+
118
+ export interface MCPServerConfig {
119
+ /** Unique identifier for this connection */
120
+ id: string
121
+ /** Command to spawn the MCP server */
122
+ command: string
123
+ /** Command arguments */
124
+ args?: string[]
125
+ /** Working directory for the subprocess */
126
+ cwd?: string
127
+ /** Environment variables for the subprocess */
128
+ env?: Record<string, string>
129
+ /** Role: 'memory' creates a MemoryProvider adapter, 'tools' exposes tools to agent */
130
+ role?: 'memory' | 'tools' | 'both'
131
+ /** Connection timeout in ms. Default: 10000 */
132
+ timeoutMs?: number
133
+ /** Auto-restart on crash. Default: true */
134
+ autoRestart?: boolean
135
+ }
136
+
137
+ // ════════════════════════════════════════════════════════════
138
+ // Standard JSON-RPC error codes
139
+ // ════════════════════════════════════════════════════════════
140
+
141
+ export const JSON_RPC_ERRORS = {
142
+ PARSE_ERROR: -32700,
143
+ INVALID_REQUEST: -32600,
144
+ METHOD_NOT_FOUND: -32601,
145
+ INVALID_PARAMS: -32602,
146
+ INTERNAL_ERROR: -32603,
147
+ } as const