@zooid/context-mcp 0.7.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/dist/bin.js +43 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-QQD76TLH.js +203 -0
- package/dist/chunk-QQD76TLH.js.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bin.ts +49 -0
- package/src/daemon-socket.test.ts +221 -0
- package/src/daemon-socket.ts +148 -0
- package/src/factory.test.ts +32 -0
- package/src/factory.ts +35 -0
- package/src/index.ts +6 -0
- package/src/integration.test.ts +138 -0
- package/src/mcp-server.test.ts +186 -0
- package/src/mcp-server.ts +92 -0
- package/src/spawn-registry.test.ts +58 -0
- package/src/spawn-registry.ts +25 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { SpawnRegistry } from './spawn-registry.js'
|
|
6
|
+
import { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
|
|
7
|
+
import type { TransportContextProvider } from '@zooid/core'
|
|
8
|
+
|
|
9
|
+
function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
|
|
10
|
+
return {
|
|
11
|
+
getRoomHistory: async () => ({ messages: [], has_more: false }),
|
|
12
|
+
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
13
|
+
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
14
|
+
getChannelMembers: async () => [],
|
|
15
|
+
getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
|
|
16
|
+
...over,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const defaultProvider = fakeProvider({
|
|
21
|
+
getRoomHistory: async () => ({
|
|
22
|
+
messages: [
|
|
23
|
+
{ id: 'e1', sender: 'alice', text: 'hi', timestamp: '2026-05-11T00:00:00Z', is_agent: false },
|
|
24
|
+
],
|
|
25
|
+
has_more: false,
|
|
26
|
+
}),
|
|
27
|
+
getChannelMembers: async () => [{ id: '@alice:hs', name: 'alice', is_agent: false }],
|
|
28
|
+
getChannelInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const cleanup: Array<() => Promise<void>> = []
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
for (const fn of cleanup) await fn()
|
|
34
|
+
cleanup.length = 0
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('daemon-socket', () => {
|
|
38
|
+
it('routes a getRoomHistory call to the bound provider and returns the payload', async () => {
|
|
39
|
+
const registry = new SpawnRegistry()
|
|
40
|
+
const spawnId = registry.register({
|
|
41
|
+
agentName: 'a',
|
|
42
|
+
threadRef: { channelId: 'c', threadId: 't' },
|
|
43
|
+
provider: defaultProvider,
|
|
44
|
+
})
|
|
45
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
46
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
47
|
+
cleanup.push(() => server.close())
|
|
48
|
+
|
|
49
|
+
const res = await callDaemon(sockPath, {
|
|
50
|
+
spawnId,
|
|
51
|
+
method: 'getRoomHistory',
|
|
52
|
+
params: { limit: 50 },
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(res).toEqual({
|
|
56
|
+
messages: [
|
|
57
|
+
{ id: 'e1', sender: 'alice', text: 'hi', timestamp: '2026-05-11T00:00:00Z', is_agent: false },
|
|
58
|
+
],
|
|
59
|
+
has_more: false,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('routes getRecentThreads and getThreadHistory with thread metadata', async () => {
|
|
64
|
+
const provider = fakeProvider({
|
|
65
|
+
getRecentThreads: async () => ({
|
|
66
|
+
threads: [
|
|
67
|
+
{
|
|
68
|
+
id: '$root',
|
|
69
|
+
sender: 'alice',
|
|
70
|
+
text: 'kickoff',
|
|
71
|
+
timestamp: 'T',
|
|
72
|
+
is_agent: false,
|
|
73
|
+
reply_count: 2,
|
|
74
|
+
last_activity_at: 'T2',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
has_more: false,
|
|
78
|
+
}),
|
|
79
|
+
getThreadHistory: async (_c, threadId) => ({
|
|
80
|
+
messages: [
|
|
81
|
+
{ id: threadId, sender: 'alice', text: 'root', timestamp: 'T', is_agent: false, thread_id: threadId },
|
|
82
|
+
],
|
|
83
|
+
has_more: false,
|
|
84
|
+
}),
|
|
85
|
+
})
|
|
86
|
+
const registry = new SpawnRegistry()
|
|
87
|
+
const spawnId = registry.register({
|
|
88
|
+
agentName: 'a',
|
|
89
|
+
threadRef: { channelId: 'c', threadId: 't' },
|
|
90
|
+
provider,
|
|
91
|
+
})
|
|
92
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
93
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
94
|
+
cleanup.push(() => server.close())
|
|
95
|
+
|
|
96
|
+
const overview = (await callDaemon(sockPath, {
|
|
97
|
+
spawnId,
|
|
98
|
+
method: 'getRecentThreads',
|
|
99
|
+
params: {},
|
|
100
|
+
})) as { threads: Array<{ id: string; reply_count: number }> }
|
|
101
|
+
expect(overview.threads[0]).toMatchObject({ id: '$root', reply_count: 2 })
|
|
102
|
+
|
|
103
|
+
const detail = (await callDaemon(sockPath, {
|
|
104
|
+
spawnId,
|
|
105
|
+
method: 'getThreadHistory',
|
|
106
|
+
params: { threadId: '$root' },
|
|
107
|
+
})) as { messages: Array<{ id: string; thread_id: string }> }
|
|
108
|
+
expect(detail.messages[0]).toMatchObject({ id: '$root', thread_id: '$root' })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns an error envelope for unknown spawn-ids', async () => {
|
|
112
|
+
const registry = new SpawnRegistry()
|
|
113
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
114
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
115
|
+
cleanup.push(() => server.close())
|
|
116
|
+
|
|
117
|
+
await expect(
|
|
118
|
+
callDaemon(sockPath, { spawnId: 'unknown', method: 'getRoomHistory', params: {} }),
|
|
119
|
+
).rejects.toThrow(/unknown spawn/i)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('routes getChannelMembers and getChannelInfo', async () => {
|
|
123
|
+
const registry = new SpawnRegistry()
|
|
124
|
+
const spawnId = registry.register({
|
|
125
|
+
agentName: 'a',
|
|
126
|
+
threadRef: { channelId: 'c', threadId: 't' },
|
|
127
|
+
provider: defaultProvider,
|
|
128
|
+
})
|
|
129
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
130
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
131
|
+
cleanup.push(() => server.close())
|
|
132
|
+
|
|
133
|
+
const members = await callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} })
|
|
134
|
+
expect(members).toEqual([{ id: '@alice:hs', name: 'alice', is_agent: false }])
|
|
135
|
+
|
|
136
|
+
const info = await callDaemon(sockPath, { spawnId, method: 'getChannelInfo', params: {} })
|
|
137
|
+
expect(info).toEqual({ id: '!r:hs', name: 'general', transport: 'matrix' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('serves two different spawns on one shared socket — each routed to its own provider', async () => {
|
|
141
|
+
const providerA = fakeProvider({
|
|
142
|
+
getRoomHistory: async () => ({
|
|
143
|
+
messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
|
|
144
|
+
has_more: false,
|
|
145
|
+
}),
|
|
146
|
+
getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
|
|
147
|
+
})
|
|
148
|
+
const providerB = fakeProvider({
|
|
149
|
+
getRoomHistory: async () => ({
|
|
150
|
+
messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
|
|
151
|
+
has_more: false,
|
|
152
|
+
}),
|
|
153
|
+
getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
|
|
154
|
+
})
|
|
155
|
+
const registry = new SpawnRegistry()
|
|
156
|
+
const spawnA = registry.register({
|
|
157
|
+
agentName: 'architect',
|
|
158
|
+
threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
|
|
159
|
+
provider: providerA,
|
|
160
|
+
})
|
|
161
|
+
const spawnB = registry.register({
|
|
162
|
+
agentName: 'product-owner',
|
|
163
|
+
threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
|
|
164
|
+
provider: providerB,
|
|
165
|
+
})
|
|
166
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
167
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
168
|
+
cleanup.push(() => server.close())
|
|
169
|
+
|
|
170
|
+
const [resA, resB] = await Promise.all([
|
|
171
|
+
callDaemon(sockPath, { spawnId: spawnA, method: 'getRoomHistory', params: {} }),
|
|
172
|
+
callDaemon(sockPath, { spawnId: spawnB, method: 'getRoomHistory', params: {} }),
|
|
173
|
+
])
|
|
174
|
+
|
|
175
|
+
expect((resA as { messages: Array<{ id: string }> }).messages[0].id).toBe('A1')
|
|
176
|
+
expect((resB as { messages: Array<{ id: string }> }).messages[0].id).toBe('B1')
|
|
177
|
+
|
|
178
|
+
const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getChannelInfo', params: {} })
|
|
179
|
+
expect((infoB as { id: string }).id).toBe('!b:hs')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('handles many sequential calls from one client connection without leaking state', async () => {
|
|
183
|
+
const providerA = fakeProvider({
|
|
184
|
+
getRoomHistory: async () => ({
|
|
185
|
+
messages: [{ id: 'A', sender: 'a', text: 'a', timestamp: 'T', is_agent: false }],
|
|
186
|
+
has_more: false,
|
|
187
|
+
}),
|
|
188
|
+
})
|
|
189
|
+
const providerB = fakeProvider({
|
|
190
|
+
getRoomHistory: async () => ({
|
|
191
|
+
messages: [{ id: 'B', sender: 'b', text: 'b', timestamp: 'T', is_agent: false }],
|
|
192
|
+
has_more: false,
|
|
193
|
+
}),
|
|
194
|
+
})
|
|
195
|
+
const registry = new SpawnRegistry()
|
|
196
|
+
const spawnA = registry.register({
|
|
197
|
+
agentName: 'a',
|
|
198
|
+
threadRef: { channelId: 'a', threadId: 'a' },
|
|
199
|
+
provider: providerA,
|
|
200
|
+
})
|
|
201
|
+
const spawnB = registry.register({
|
|
202
|
+
agentName: 'b',
|
|
203
|
+
threadRef: { channelId: 'b', threadId: 'b' },
|
|
204
|
+
provider: providerB,
|
|
205
|
+
})
|
|
206
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
207
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
208
|
+
cleanup.push(() => server.close())
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < 20; i++) {
|
|
211
|
+
const spawnId = i % 2 === 0 ? spawnA : spawnB
|
|
212
|
+
const expected = i % 2 === 0 ? 'A' : 'B'
|
|
213
|
+
const res = (await callDaemon(sockPath, {
|
|
214
|
+
spawnId,
|
|
215
|
+
method: 'getRoomHistory',
|
|
216
|
+
params: {},
|
|
217
|
+
})) as { messages: Array<{ id: string }> }
|
|
218
|
+
expect(res.messages[0].id).toBe(expected)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createServer, createConnection, type Server, type Socket } from 'node:net'
|
|
2
|
+
import { unlink } from 'node:fs/promises'
|
|
3
|
+
import type { SpawnRegistry } from './spawn-registry.js'
|
|
4
|
+
|
|
5
|
+
interface DaemonRequest {
|
|
6
|
+
spawnId: string
|
|
7
|
+
method:
|
|
8
|
+
| 'getRoomHistory'
|
|
9
|
+
| 'getRecentThreads'
|
|
10
|
+
| 'getThreadHistory'
|
|
11
|
+
| 'getChannelMembers'
|
|
12
|
+
| 'getChannelInfo'
|
|
13
|
+
params: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DaemonResponse {
|
|
17
|
+
ok: true
|
|
18
|
+
result: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DaemonError {
|
|
22
|
+
ok: false
|
|
23
|
+
error: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DaemonSocketHandle {
|
|
27
|
+
close(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function startDaemonSocketServer(opts: {
|
|
31
|
+
sockPath: string
|
|
32
|
+
registry: SpawnRegistry
|
|
33
|
+
}): Promise<DaemonSocketHandle> {
|
|
34
|
+
await unlink(opts.sockPath).catch(() => {})
|
|
35
|
+
const server: Server = createServer((socket: Socket) => {
|
|
36
|
+
let buf = ''
|
|
37
|
+
socket.setEncoding('utf8')
|
|
38
|
+
socket.on('data', async (chunk) => {
|
|
39
|
+
buf += chunk
|
|
40
|
+
let idx: number
|
|
41
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
42
|
+
const line = buf.slice(0, idx)
|
|
43
|
+
buf = buf.slice(idx + 1)
|
|
44
|
+
if (!line) continue
|
|
45
|
+
await handleLine(line, socket, opts.registry)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
socket.on('error', () => {})
|
|
49
|
+
})
|
|
50
|
+
await new Promise<void>((resolve, reject) => {
|
|
51
|
+
server.once('error', reject)
|
|
52
|
+
server.listen(opts.sockPath, () => {
|
|
53
|
+
server.removeListener('error', reject)
|
|
54
|
+
resolve()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
return {
|
|
58
|
+
close: () =>
|
|
59
|
+
new Promise<void>((resolve) => {
|
|
60
|
+
server.close(() => resolve())
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handleLine(line: string, socket: Socket, registry: SpawnRegistry) {
|
|
66
|
+
let req: DaemonRequest
|
|
67
|
+
try {
|
|
68
|
+
req = JSON.parse(line) as DaemonRequest
|
|
69
|
+
} catch {
|
|
70
|
+
socket.write(JSON.stringify({ ok: false, error: 'invalid json' } satisfies DaemonError) + '\n')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const binding = registry.get(req.spawnId)
|
|
74
|
+
if (!binding) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`[context-mcp] daemon: unknown spawn-id ${req.spawnId} for method=${req.method}\n`,
|
|
77
|
+
)
|
|
78
|
+
socket.write(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `unknown spawn-id: ${req.spawnId}`,
|
|
82
|
+
} satisfies DaemonError) + '\n',
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[context-mcp] daemon: ${req.method} spawn=${req.spawnId.slice(0, 8)} agent=${binding.agentName}\n`,
|
|
88
|
+
)
|
|
89
|
+
try {
|
|
90
|
+
let result: unknown
|
|
91
|
+
const channelId = binding.threadRef.channelId
|
|
92
|
+
if (req.method === 'getRoomHistory') {
|
|
93
|
+
result = await binding.provider.getRoomHistory(channelId, req.params)
|
|
94
|
+
} else if (req.method === 'getRecentThreads') {
|
|
95
|
+
result = await binding.provider.getRecentThreads(channelId, req.params)
|
|
96
|
+
} else if (req.method === 'getThreadHistory') {
|
|
97
|
+
const threadId = String(req.params.threadId ?? '')
|
|
98
|
+
result = await binding.provider.getThreadHistory(channelId, threadId, req.params)
|
|
99
|
+
} else if (req.method === 'getChannelMembers') {
|
|
100
|
+
result = await binding.provider.getChannelMembers(channelId)
|
|
101
|
+
} else if (req.method === 'getChannelInfo') {
|
|
102
|
+
result = await binding.provider.getChannelInfo(channelId)
|
|
103
|
+
} else {
|
|
104
|
+
socket.write(
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
ok: false,
|
|
107
|
+
error: `unknown method: ${(req as { method: string }).method}`,
|
|
108
|
+
} satisfies DaemonError) + '\n',
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
socket.write(JSON.stringify({ ok: true, result } satisfies DaemonResponse) + '\n')
|
|
113
|
+
} catch (err) {
|
|
114
|
+
socket.write(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
ok: false,
|
|
117
|
+
error: String(err instanceof Error ? err.message : err),
|
|
118
|
+
} satisfies DaemonError) + '\n',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function callDaemon(sockPath: string, req: DaemonRequest): Promise<unknown> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const socket = createConnection(sockPath)
|
|
126
|
+
let buf = ''
|
|
127
|
+
socket.setEncoding('utf8')
|
|
128
|
+
socket.on('error', reject)
|
|
129
|
+
socket.on('data', (chunk) => {
|
|
130
|
+
buf += chunk
|
|
131
|
+
const idx = buf.indexOf('\n')
|
|
132
|
+
if (idx < 0) return
|
|
133
|
+
const line = buf.slice(0, idx)
|
|
134
|
+
let parsed: DaemonResponse | DaemonError
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(line) as DaemonResponse | DaemonError
|
|
137
|
+
} catch (e) {
|
|
138
|
+
socket.end()
|
|
139
|
+
reject(e instanceof Error ? e : new Error(String(e)))
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
socket.end()
|
|
143
|
+
if (parsed.ok) resolve(parsed.result)
|
|
144
|
+
else reject(new Error(parsed.error))
|
|
145
|
+
})
|
|
146
|
+
socket.write(JSON.stringify(req) + '\n')
|
|
147
|
+
})
|
|
148
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { buildContextServerSpec } from './factory.js'
|
|
4
|
+
|
|
5
|
+
describe('buildContextServerSpec', () => {
|
|
6
|
+
it('produces the ACP mcpServers entry the daemon passes to session/new', () => {
|
|
7
|
+
const spec = buildContextServerSpec({
|
|
8
|
+
spawnId: '11111111-1111-4111-8111-111111111111',
|
|
9
|
+
sockPath: '/run/zooid/abc.sock',
|
|
10
|
+
binPath: '/usr/local/lib/zooid/zooid-context-mcp.js',
|
|
11
|
+
})
|
|
12
|
+
expect(spec.name).toBe('zooid-context')
|
|
13
|
+
expect(spec.command).toBe(process.execPath)
|
|
14
|
+
expect(spec.args[0]).toBe('/usr/local/lib/zooid/zooid-context-mcp.js')
|
|
15
|
+
expect(spec.args).toContain('--spawn-id')
|
|
16
|
+
expect(spec.args).toContain('11111111-1111-4111-8111-111111111111')
|
|
17
|
+
expect(spec.env).toEqual([{ name: 'ZOOID_DAEMON_SOCK', value: '/run/zooid/abc.sock' }])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('resolves the default bin via Node module resolution to a real path on disk', () => {
|
|
21
|
+
// Pinned regression: tsup bundles this file into the CLI chunk, so the
|
|
22
|
+
// earlier `import.meta.url`-based default broke at runtime (it resolved
|
|
23
|
+
// to `<cli-dist>/bin.js`, not this package's dist). `createRequire` must
|
|
24
|
+
// walk node_modules and return an actual file path.
|
|
25
|
+
const spec = buildContextServerSpec({
|
|
26
|
+
spawnId: 'sid',
|
|
27
|
+
sockPath: '/tmp/x.sock',
|
|
28
|
+
})
|
|
29
|
+
expect(spec.args[0]).toMatch(/context-mcp[/\\]dist[/\\]bin\.js$/)
|
|
30
|
+
expect(existsSync(spec.args[0])).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
})
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import type { ZooidContextServerSpec } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve `dist/bin.js` via Node's runtime module resolver rather than
|
|
6
|
+
* `import.meta.url`. tsup bundles this file into the CLI's own chunk, so
|
|
7
|
+
* `import.meta.url` at runtime points to the CLI bundle — not this
|
|
8
|
+
* package's dist. `createRequire` walks the runtime `node_modules` tree
|
|
9
|
+
* instead and finds the package wherever it actually lives.
|
|
10
|
+
*/
|
|
11
|
+
function resolveDefaultBin(): string {
|
|
12
|
+
const req = createRequire(import.meta.url)
|
|
13
|
+
// Resolve via the dedicated `./bin` export → `./dist/bin.js`. Avoids the
|
|
14
|
+
// exports-restriction surprise of importing `./package.json`.
|
|
15
|
+
return req.resolve('@zooid/context-mcp/bin')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let cachedDefaultBin: string | null = null
|
|
19
|
+
function getDefaultBin(): string {
|
|
20
|
+
if (!cachedDefaultBin) cachedDefaultBin = resolveDefaultBin()
|
|
21
|
+
return cachedDefaultBin
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildContextServerSpec(opts: {
|
|
25
|
+
spawnId: string
|
|
26
|
+
sockPath: string
|
|
27
|
+
binPath?: string
|
|
28
|
+
}): ZooidContextServerSpec {
|
|
29
|
+
return {
|
|
30
|
+
name: 'zooid-context',
|
|
31
|
+
command: process.execPath,
|
|
32
|
+
args: [opts.binPath ?? getDefaultBin(), '--spawn-id', opts.spawnId],
|
|
33
|
+
env: [{ name: 'ZOOID_DAEMON_SOCK', value: opts.sockPath }],
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SpawnRegistry } from './spawn-registry.js'
|
|
2
|
+
export { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
|
|
3
|
+
export type { DaemonSocketHandle } from './daemon-socket.js'
|
|
4
|
+
export { buildContextMcpServer } from './mcp-server.js'
|
|
5
|
+
export { buildContextServerSpec } from './factory.js'
|
|
6
|
+
export type { SpawnBinding, ZooidContextServerSpec } from './types.js'
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
8
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
9
|
+
import { SpawnRegistry } from './spawn-registry.js'
|
|
10
|
+
import { startDaemonSocketServer } from './daemon-socket.js'
|
|
11
|
+
import type { TransportContextProvider } from '@zooid/core'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const BIN = join(__dirname, '..', 'dist', 'bin.js')
|
|
15
|
+
|
|
16
|
+
const cleanup: Array<() => Promise<void>> = []
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
for (const fn of cleanup) await fn()
|
|
19
|
+
cleanup.length = 0
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
|
|
23
|
+
return {
|
|
24
|
+
getRoomHistory: async () => ({ messages: [], has_more: false }),
|
|
25
|
+
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
26
|
+
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
27
|
+
getChannelMembers: async () => [],
|
|
28
|
+
getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
|
|
29
|
+
...over,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', () => {
|
|
34
|
+
it('end-to-end tools/call → daemon socket → provider → tool result', async () => {
|
|
35
|
+
const provider = fakeProvider({
|
|
36
|
+
getRoomHistory: async () => ({
|
|
37
|
+
messages: [
|
|
38
|
+
{ id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false },
|
|
39
|
+
],
|
|
40
|
+
has_more: false,
|
|
41
|
+
}),
|
|
42
|
+
})
|
|
43
|
+
const registry = new SpawnRegistry()
|
|
44
|
+
const spawnId = registry.register({
|
|
45
|
+
agentName: 'architect',
|
|
46
|
+
threadRef: { channelId: '!room:hs', threadId: '!room:hs' },
|
|
47
|
+
provider,
|
|
48
|
+
})
|
|
49
|
+
const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
|
|
50
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
51
|
+
cleanup.push(() => server.close())
|
|
52
|
+
|
|
53
|
+
const transport = new StdioClientTransport({
|
|
54
|
+
command: process.execPath,
|
|
55
|
+
args: [BIN, '--spawn-id', spawnId],
|
|
56
|
+
env: { ...process.env, ZOOID_DAEMON_SOCK: sockPath } as Record<string, string>,
|
|
57
|
+
})
|
|
58
|
+
const client = new Client({ name: 'it', version: '0.0.1' }, { capabilities: {} })
|
|
59
|
+
await client.connect(transport)
|
|
60
|
+
cleanup.push(async () => {
|
|
61
|
+
await client.close()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const list = await client.listTools()
|
|
65
|
+
expect(list.tools.map((t) => t.name).sort()).toEqual([
|
|
66
|
+
'zooid_get_channel_info',
|
|
67
|
+
'zooid_get_history',
|
|
68
|
+
'zooid_get_members',
|
|
69
|
+
'zooid_get_recent_threads',
|
|
70
|
+
'zooid_get_thread_history',
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
const result = await client.callTool({ name: 'zooid_get_history', arguments: {} })
|
|
74
|
+
const payload = JSON.parse((result.content as Array<{ text: string }>)[0].text)
|
|
75
|
+
expect(payload.messages[0].id).toBe('e1')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('two MCP server subprocesses sharing one socket route to their own bindings', async () => {
|
|
79
|
+
const providerA = fakeProvider({
|
|
80
|
+
getRoomHistory: async () => ({
|
|
81
|
+
messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
|
|
82
|
+
has_more: false,
|
|
83
|
+
}),
|
|
84
|
+
getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
|
|
85
|
+
})
|
|
86
|
+
const providerB = fakeProvider({
|
|
87
|
+
getRoomHistory: async () => ({
|
|
88
|
+
messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
|
|
89
|
+
has_more: false,
|
|
90
|
+
}),
|
|
91
|
+
getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
|
|
92
|
+
})
|
|
93
|
+
const registry = new SpawnRegistry()
|
|
94
|
+
const spawnA = registry.register({
|
|
95
|
+
agentName: 'architect',
|
|
96
|
+
threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
|
|
97
|
+
provider: providerA,
|
|
98
|
+
})
|
|
99
|
+
const spawnB = registry.register({
|
|
100
|
+
agentName: 'product-owner',
|
|
101
|
+
threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
|
|
102
|
+
provider: providerB,
|
|
103
|
+
})
|
|
104
|
+
const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
|
|
105
|
+
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
106
|
+
cleanup.push(() => server.close())
|
|
107
|
+
|
|
108
|
+
async function startClient(spawnId: string) {
|
|
109
|
+
const transport = new StdioClientTransport({
|
|
110
|
+
command: process.execPath,
|
|
111
|
+
args: [BIN, '--spawn-id', spawnId],
|
|
112
|
+
env: { ...process.env, ZOOID_DAEMON_SOCK: sockPath } as Record<string, string>,
|
|
113
|
+
})
|
|
114
|
+
const client = new Client({ name: 'it', version: '0.0.1' }, { capabilities: {} })
|
|
115
|
+
await client.connect(transport)
|
|
116
|
+
cleanup.push(async () => {
|
|
117
|
+
await client.close()
|
|
118
|
+
})
|
|
119
|
+
return client
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const [clientA, clientB] = await Promise.all([startClient(spawnA), startClient(spawnB)])
|
|
123
|
+
|
|
124
|
+
const [resA, resB] = await Promise.all([
|
|
125
|
+
clientA.callTool({ name: 'zooid_get_history', arguments: {} }),
|
|
126
|
+
clientB.callTool({ name: 'zooid_get_history', arguments: {} }),
|
|
127
|
+
])
|
|
128
|
+
const payloadA = JSON.parse((resA.content as Array<{ text: string }>)[0].text)
|
|
129
|
+
const payloadB = JSON.parse((resB.content as Array<{ text: string }>)[0].text)
|
|
130
|
+
expect(payloadA.messages[0].id).toBe('A1')
|
|
131
|
+
expect(payloadB.messages[0].id).toBe('B1')
|
|
132
|
+
|
|
133
|
+
const infoA = await clientA.callTool({ name: 'zooid_get_channel_info', arguments: {} })
|
|
134
|
+
const infoB = await clientB.callTool({ name: 'zooid_get_channel_info', arguments: {} })
|
|
135
|
+
expect(JSON.parse((infoA.content as Array<{ text: string }>)[0].text).id).toBe('!a:hs')
|
|
136
|
+
expect(JSON.parse((infoB.content as Array<{ text: string }>)[0].text).id).toBe('!b:hs')
|
|
137
|
+
})
|
|
138
|
+
})
|