@zooid/context-mcp 0.12.0 → 0.14.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.
@@ -1,15 +1,23 @@
1
1
  import { createServer, createConnection, type Server, type Socket } from 'node:net'
2
2
  import { unlink } from 'node:fs/promises'
3
3
  import type { SpawnRegistry } from './spawn-registry.js'
4
+ import type { CompleteTaskInput, StartTasksInput } from '@zooid/core'
5
+ import { agentSocketPath } from './socket-paths.js'
4
6
 
5
- interface DaemonRequest {
6
- spawnId: string
7
+ export interface DaemonRequest {
8
+ spawnId?: string
9
+ acpSessionId?: string
7
10
  method:
8
11
  | 'getRoomHistory'
9
12
  | 'getRecentThreads'
10
13
  | 'getThreadHistory'
11
14
  | 'getChannelMembers'
12
- | 'getChannelInfo'
15
+ | 'getRoomInfo'
16
+ | 'getRooms'
17
+ | 'sendMessage'
18
+ | 'startTasks'
19
+ | 'completeTask'
20
+ | 'describeRole'
13
21
  params: Record<string, unknown>
14
22
  }
15
23
 
@@ -27,12 +35,33 @@ export interface DaemonSocketHandle {
27
35
  close(): Promise<void>
28
36
  }
29
37
 
38
+ export interface AgentSocketsHandle {
39
+ /** Agent → host socket path. A missing path means its listener failed. */
40
+ paths: Record<string, string | undefined>
41
+ close(): Promise<void>
42
+ }
43
+
44
+ /** Same wire error for absent and foreign bindings, so sockets are not an ID oracle. */
45
+ const NOT_OWNED = 'binding not owned by caller'
46
+
30
47
  export async function startDaemonSocketServer(opts: {
31
48
  sockPath: string
32
49
  registry: SpawnRegistry
50
+ /** The only agent whose bindings this listener may serve. */
51
+ agentName: string
33
52
  }): Promise<DaemonSocketHandle> {
34
53
  await unlink(opts.sockPath).catch(() => {})
54
+ // net.Server.close() waits for every open connection and — unlike
55
+ // http.Server since Node 19 — never destroys idle ones, and there is no
56
+ // closeAllConnections() for it. The clients here are context-mcp servers
57
+ // spawned beside each agent, which outlive AcpClient.stop() (it SIGTERMs
58
+ // the agent without waiting, and these are its grandchildren). So without
59
+ // tracking them, close() hangs until they happen to die — which is what
60
+ // made `zooid dev` take ages to stop.
61
+ const open = new Set<Socket>()
35
62
  const server: Server = createServer((socket: Socket) => {
63
+ open.add(socket)
64
+ socket.on('close', () => open.delete(socket))
36
65
  let buf = ''
37
66
  socket.setEncoding('utf8')
38
67
  socket.on('data', async (chunk) => {
@@ -42,7 +71,7 @@ export async function startDaemonSocketServer(opts: {
42
71
  const line = buf.slice(0, idx)
43
72
  buf = buf.slice(idx + 1)
44
73
  if (!line) continue
45
- await handleLine(line, socket, opts.registry)
74
+ await handleLine(line, socket, opts.registry, opts.agentName)
46
75
  }
47
76
  })
48
77
  socket.on('error', () => {})
@@ -58,33 +87,61 @@ export async function startDaemonSocketServer(opts: {
58
87
  close: () =>
59
88
  new Promise<void>((resolve) => {
60
89
  server.close(() => resolve())
90
+ // Shutdown is already tearing the agents down, so there is no
91
+ // in-flight request worth waiting for. Drop the connections that
92
+ // would otherwise keep close() pending forever.
93
+ for (const socket of open) socket.destroy()
94
+ open.clear()
61
95
  }),
62
96
  }
63
97
  }
64
98
 
65
- async function handleLine(line: string, socket: Socket, registry: SpawnRegistry) {
99
+ async function handleLine(
100
+ line: string,
101
+ socket: Socket,
102
+ registry: SpawnRegistry,
103
+ callerAgent: string,
104
+ ) {
66
105
  let req: DaemonRequest
67
106
  try {
68
107
  req = JSON.parse(line) as DaemonRequest
69
108
  } catch {
70
- socket.write(JSON.stringify({ ok: false, error: 'invalid json' } satisfies DaemonError) + '\n')
109
+ socket.write(
110
+ JSON.stringify({
111
+ ok: false,
112
+ error: 'invalid json',
113
+ } satisfies DaemonError) + '\n',
114
+ )
71
115
  return
72
116
  }
73
- const binding = registry.get(req.spawnId)
117
+ const binding = req.spawnId
118
+ ? registry.get(req.spawnId)
119
+ : req.acpSessionId
120
+ ? registry.getByAcpSession(req.acpSessionId)
121
+ : undefined
74
122
  if (!binding) {
123
+ const address = req.acpSessionId ? `session: ${req.acpSessionId}` : `spawn-id: ${req.spawnId ?? ''}`
75
124
  process.stderr.write(
76
- `[context-mcp] daemon: unknown spawn-id ${req.spawnId} for method=${req.method}\n`,
125
+ `[context-mcp] daemon: unknown ${address} for method=${req.method}\n`,
77
126
  )
78
127
  socket.write(
79
128
  JSON.stringify({
80
129
  ok: false,
81
- error: `unknown spawn-id: ${req.spawnId}`,
130
+ error: NOT_OWNED,
82
131
  } satisfies DaemonError) + '\n',
83
132
  )
84
133
  return
85
134
  }
135
+ if (binding.agentName !== callerAgent) {
136
+ process.stderr.write(
137
+ `[context-mcp] daemon: refused ${req.method} — binding owned by ${binding.agentName}, ` +
138
+ `caller is ${callerAgent}\n`,
139
+ )
140
+ socket.write(JSON.stringify({ ok: false, error: NOT_OWNED } satisfies DaemonError) + '\n')
141
+ return
142
+ }
86
143
  process.stderr.write(
87
- `[context-mcp] daemon: ${req.method} spawn=${req.spawnId.slice(0, 8)} agent=${binding.agentName}\n`,
144
+ `[context-mcp] daemon: ${req.method} spawn=${binding.spawnId.slice(0, 8)} agent=${binding.agentName}\n`,
88
145
  )
89
146
  try {
90
147
  let result: unknown
@@ -98,8 +155,41 @@ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry)
98
155
  result = await binding.provider.getThreadHistory(channelId, threadId, req.params)
99
156
  } else if (req.method === 'getChannelMembers') {
100
157
  result = await binding.provider.getChannelMembers(channelId)
101
- } else if (req.method === 'getChannelInfo') {
102
- result = await binding.provider.getChannelInfo(channelId)
158
+ } else if (req.method === 'getRoomInfo') {
159
+ result = await binding.provider.getRoomInfo(channelId)
160
+ } else if (req.method === 'getRooms') {
161
+ result = await binding.provider.getRooms()
162
+ } else if (req.method === 'sendMessage') {
163
+ result = await binding.provider.sendMessage(
164
+ req.params as unknown as Parameters<typeof binding.provider.sendMessage>[0],
165
+ )
166
+ } else if (
167
+ req.method === 'startTasks' ||
168
+ req.method === 'completeTask' ||
169
+ req.method === 'describeRole'
170
+ ) {
171
+ const actions = registry.taskActions
172
+ if (!actions) {
173
+ socket.write(
174
+ JSON.stringify({
175
+ ok: false,
176
+ error: 'task actions unavailable',
177
+ } satisfies DaemonError) + '\n',
178
+ )
179
+ return
180
+ }
181
+ const caller = {
182
+ agentName: binding.agentName,
183
+ channelId,
184
+ threadRoot: binding.threadRef.threadId,
185
+ sessionKey: binding.sessionKey ?? binding.threadRef.threadId,
186
+ }
187
+ result =
188
+ req.method === 'startTasks'
189
+ ? await actions.startTasks(caller, req.params as unknown as StartTasksInput)
190
+ : req.method === 'completeTask'
191
+ ? await actions.completeTask(caller, req.params as unknown as CompleteTaskInput)
192
+ : await actions.describeRole(caller)
103
193
  } else {
104
194
  socket.write(
105
195
  JSON.stringify({
@@ -120,6 +210,43 @@ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry)
120
210
  }
121
211
  }
122
212
 
213
+ /** Start independent context listeners; a bind failure disables only that agent. */
214
+ export async function startAgentSocketServers(opts: {
215
+ runDir: string
216
+ registry: SpawnRegistry
217
+ agentNames: string[]
218
+ listen?: (sockPath: string, agentName: string) => Promise<DaemonSocketHandle>
219
+ }): Promise<AgentSocketsHandle> {
220
+ const listen =
221
+ opts.listen ??
222
+ ((sockPath: string, agentName: string) =>
223
+ startDaemonSocketServer({ sockPath, registry: opts.registry, agentName }))
224
+ const derived = opts.agentNames.map((agentName) => ({
225
+ agentName,
226
+ sockPath: agentSocketPath({ runDir: opts.runDir, agentName }),
227
+ }))
228
+ const paths: Record<string, string | undefined> = {}
229
+ const handles: Array<{ handle: DaemonSocketHandle; path: string }> = []
230
+ for (const { agentName, sockPath } of derived) {
231
+ try {
232
+ const handle = await listen(sockPath, agentName)
233
+ paths[agentName] = sockPath
234
+ handles.push({ handle, path: sockPath })
235
+ } catch (err) {
236
+ console.warn(`[context] socket bind failed for agent=${agentName}; context disabled for it:`, err)
237
+ }
238
+ }
239
+ return {
240
+ paths,
241
+ close: async () => {
242
+ for (const { handle, path } of handles) {
243
+ await handle.close()
244
+ await unlink(path).catch(() => {})
245
+ }
246
+ },
247
+ }
248
+ }
249
+
123
250
  export async function callDaemon(sockPath: string, req: DaemonRequest): Promise<unknown> {
124
251
  return new Promise((resolve, reject) => {
125
252
  const socket = createConnection(sockPath)
@@ -82,6 +82,16 @@ describe('contextContainerMounts', () => {
82
82
  ])
83
83
  })
84
84
 
85
+ it('uses each agent host socket at the same container target', () => {
86
+ const alice = contextContainerMounts({ sockPath: '/data/run/context-alice.sock' })
87
+ const bob = contextContainerMounts({ sockPath: '/data/run/context-bob.sock' })
88
+ const aliceSocket = alice.find((mount) => mount.target === CONTEXT_CONTAINER_SOCK)!
89
+ const bobSocket = bob.find((mount) => mount.target === CONTEXT_CONTAINER_SOCK)!
90
+ expect(aliceSocket.path).not.toBe(bobSocket.path)
91
+ expect(aliceSocket).toMatchObject({ target: CONTEXT_CONTAINER_SOCK, mode: 'rw' })
92
+ expect(bobSocket.target).toBe(CONTEXT_CONTAINER_SOCK)
93
+ })
94
+
85
95
  it('defaults the bin dir to the resolved package dist on disk', () => {
86
96
  const mounts = contextContainerMounts({ sockPath: '/tmp/x.sock' })
87
97
  const binMount = mounts.find((m) => m.target === CONTEXT_CONTAINER_BIN_DIR)!
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { SpawnRegistry } from './spawn-registry.js'
2
- export { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
3
- export type { DaemonSocketHandle } from './daemon-socket.js'
2
+ export { startDaemonSocketServer, startAgentSocketServers, callDaemon } from './daemon-socket.js'
3
+ export type { DaemonRequest, DaemonSocketHandle, AgentSocketsHandle } from './daemon-socket.js'
4
+ export { agentSocketPath, SUN_PATH_MAX } from './socket-paths.js'
4
5
  export { buildContextMcpServer } from './mcp-server.js'
5
6
  export {
6
7
  buildContextServerSpec,
@@ -3,11 +3,12 @@ import { fileURLToPath } from 'node:url'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { randomUUID } from 'node:crypto'
6
- import { existsSync } from 'node:fs'
6
+ import { existsSync, mkdtempSync } from 'node:fs'
7
7
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
8
8
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
9
9
  import { SpawnRegistry } from './spawn-registry.js'
10
- import { startDaemonSocketServer } from './daemon-socket.js'
10
+ import { callDaemon, startAgentSocketServers, startDaemonSocketServer } from './daemon-socket.js'
11
+ import { agentSocketPath } from './socket-paths.js'
11
12
  import type { TransportContextProvider } from '@zooid/core'
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -25,7 +26,7 @@ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
25
26
  getRecentThreads: async () => ({ threads: [], has_more: false }),
26
27
  getThreadHistory: async () => ({ messages: [], has_more: false }),
27
28
  getChannelMembers: async () => [],
28
- getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
29
+ getRoomInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
29
30
  ...over,
30
31
  }
31
32
  }
@@ -35,7 +36,13 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
35
36
  const provider = fakeProvider({
36
37
  getRoomHistory: async () => ({
37
38
  messages: [
38
- { id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false },
39
+ {
40
+ id: 'e1',
41
+ sender: 'alice',
42
+ text: 'hi',
43
+ timestamp: 'T',
44
+ is_agent: false,
45
+ },
39
46
  ],
40
47
  has_more: false,
41
48
  }),
@@ -47,7 +54,7 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
47
54
  provider,
48
55
  })
49
56
  const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
50
- const server = await startDaemonSocketServer({ sockPath, registry })
57
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'architect' })
51
58
  cleanup.push(() => server.close())
52
59
 
53
60
  const transport = new StdioClientTransport({
@@ -61,16 +68,24 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
61
68
  await client.close()
62
69
  })
63
70
 
71
+ // No taskActions registered on this registry, so the daemon-side
72
+ // describeRole query fails, bin.ts's role stays undefined, and neither
73
+ // task tool registers ([[ZOD084]] role-conditional registration).
64
74
  const list = await client.listTools()
65
75
  expect(list.tools.map((t) => t.name).sort()).toEqual([
66
- 'zooid_get_channel_info',
67
76
  'zooid_get_history',
68
77
  'zooid_get_members',
69
78
  'zooid_get_recent_threads',
79
+ 'zooid_get_room_info',
80
+ 'zooid_get_rooms',
70
81
  'zooid_get_thread_history',
82
+ 'zooid_send_message',
71
83
  ])
72
84
 
73
- const result = await client.callTool({ name: 'zooid_get_history', arguments: {} })
85
+ const result = await client.callTool({
86
+ name: 'zooid_get_history',
87
+ arguments: {},
88
+ })
74
89
  const payload = JSON.parse((result.content as Array<{ text: string }>)[0].text)
75
90
  expect(payload.messages[0].id).toBe('e1')
76
91
  })
@@ -78,17 +93,41 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
78
93
  it('two MCP server subprocesses sharing one socket route to their own bindings', async () => {
79
94
  const providerA = fakeProvider({
80
95
  getRoomHistory: async () => ({
81
- messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
96
+ messages: [
97
+ {
98
+ id: 'A1',
99
+ sender: 'alice',
100
+ text: 'from A',
101
+ timestamp: 'T',
102
+ is_agent: false,
103
+ },
104
+ ],
82
105
  has_more: false,
83
106
  }),
84
- getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
107
+ getRoomInfo: async () => ({
108
+ id: '!a:hs',
109
+ name: 'room-A',
110
+ transport: 'matrix',
111
+ }),
85
112
  })
86
113
  const providerB = fakeProvider({
87
114
  getRoomHistory: async () => ({
88
- messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
115
+ messages: [
116
+ {
117
+ id: 'B1',
118
+ sender: 'bob',
119
+ text: 'from B',
120
+ timestamp: 'T',
121
+ is_agent: false,
122
+ },
123
+ ],
89
124
  has_more: false,
90
125
  }),
91
- getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
126
+ getRoomInfo: async () => ({
127
+ id: '!b:hs',
128
+ name: 'room-B',
129
+ transport: 'matrix',
130
+ }),
92
131
  })
93
132
  const registry = new SpawnRegistry()
94
133
  const spawnA = registry.register({
@@ -97,12 +136,12 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
97
136
  provider: providerA,
98
137
  })
99
138
  const spawnB = registry.register({
100
- agentName: 'product-owner',
139
+ agentName: 'architect',
101
140
  threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
102
141
  provider: providerB,
103
142
  })
104
143
  const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
105
- const server = await startDaemonSocketServer({ sockPath, registry })
144
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'architect' })
106
145
  cleanup.push(() => server.close())
107
146
 
108
147
  async function startClient(spawnId: string) {
@@ -130,9 +169,54 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
130
169
  expect(payloadA.messages[0].id).toBe('A1')
131
170
  expect(payloadB.messages[0].id).toBe('B1')
132
171
 
133
- const infoA = await clientA.callTool({ name: 'zooid_get_channel_info', arguments: {} })
134
- const infoB = await clientB.callTool({ name: 'zooid_get_channel_info', arguments: {} })
172
+ const infoA = await clientA.callTool({
173
+ name: 'zooid_get_room_info',
174
+ arguments: {},
175
+ })
176
+ const infoB = await clientB.callTool({
177
+ name: 'zooid_get_room_info',
178
+ arguments: {},
179
+ })
135
180
  expect(JSON.parse((infoA.content as Array<{ text: string }>)[0].text).id).toBe('!a:hs')
136
181
  expect(JSON.parse((infoB.content as Array<{ text: string }>)[0].text).id).toBe('!b:hs')
137
182
  })
138
183
  })
184
+
185
+ describe('per-agent sockets (integration)', () => {
186
+ it('isolates two agents sharing a registry', async () => {
187
+ const registry = new SpawnRegistry()
188
+ const aliceSpawn = registry.register({
189
+ agentName: 'alice',
190
+ threadRef: { channelId: '!alice:hs', threadId: 'a' },
191
+ provider: fakeProvider({ getRoomInfo: async () => ({ id: '!alice:hs', name: 'alice', transport: 'matrix' }) }),
192
+ })
193
+ const bobSpawn = registry.register({
194
+ agentName: 'bob',
195
+ threadRef: { channelId: '!bob:hs', threadId: 'b' },
196
+ provider: fakeProvider({ getRoomInfo: async () => ({ id: '!bob:hs', name: 'bob', transport: 'matrix' }) }),
197
+ })
198
+ const runDir = mkdtempSync(join(tmpdir(), 'zooid-run-'))
199
+ const sockets = await startAgentSocketServers({ runDir, registry, agentNames: ['alice', 'bob'] })
200
+ cleanup.push(() => sockets.close())
201
+ expect(sockets.paths.alice).toBe(agentSocketPath({ runDir, agentName: 'alice' }))
202
+ await expect(callDaemon(sockets.paths.alice!, { spawnId: aliceSpawn, method: 'getRoomInfo', params: {} })).resolves.toMatchObject({ id: '!alice:hs' })
203
+ await expect(callDaemon(sockets.paths.bob!, { spawnId: aliceSpawn, method: 'getRoomInfo', params: {} })).rejects.toThrow(/binding not owned by caller/)
204
+ await expect(callDaemon(sockets.paths.bob!, { spawnId: bobSpawn, method: 'getRoomInfo', params: {} })).resolves.toMatchObject({ id: '!bob:hs' })
205
+ })
206
+
207
+ it('keeps other listeners serving when one bind fails', async () => {
208
+ const registry = new SpawnRegistry()
209
+ const sockets = await startAgentSocketServers({
210
+ runDir: mkdtempSync(join(tmpdir(), 'zooid-run-')),
211
+ registry,
212
+ agentNames: ['alice', 'bob'],
213
+ listen: async (path, name) => {
214
+ if (name === 'bob') throw new Error('EADDRINUSE')
215
+ return startDaemonSocketServer({ sockPath: path, registry, agentName: name })
216
+ },
217
+ })
218
+ cleanup.push(() => sockets.close())
219
+ expect(sockets.paths.alice).toBeDefined()
220
+ expect(sockets.paths.bob).toBeUndefined()
221
+ })
222
+ })