@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 +21 -0
- package/README.md +13 -0
- package/package.json +31 -0
- package/src/__tests__/connection.test.ts +351 -0
- package/src/__tests__/mock-mcp-server.ts +147 -0
- package/src/index.ts +53 -0
- package/src/lib/connection.ts +344 -0
- package/src/lib/registry.ts +212 -0
- package/src/lib/types.ts +147 -0
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
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -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
|