@zooid/context-mcp 0.13.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,9 +35,20 @@ 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(() => {})
35
54
  // net.Server.close() waits for every open connection and — unlike
@@ -52,7 +71,7 @@ export async function startDaemonSocketServer(opts: {
52
71
  const line = buf.slice(0, idx)
53
72
  buf = buf.slice(idx + 1)
54
73
  if (!line) continue
55
- await handleLine(line, socket, opts.registry)
74
+ await handleLine(line, socket, opts.registry, opts.agentName)
56
75
  }
57
76
  })
58
77
  socket.on('error', () => {})
@@ -77,29 +96,52 @@ export async function startDaemonSocketServer(opts: {
77
96
  }
78
97
  }
79
98
 
80
- 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
+ ) {
81
105
  let req: DaemonRequest
82
106
  try {
83
107
  req = JSON.parse(line) as DaemonRequest
84
108
  } catch {
85
- 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
+ )
86
115
  return
87
116
  }
88
- 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
89
122
  if (!binding) {
123
+ const address = req.acpSessionId ? `session: ${req.acpSessionId}` : `spawn-id: ${req.spawnId ?? ''}`
90
124
  process.stderr.write(
91
- `[context-mcp] daemon: unknown spawn-id ${req.spawnId} for method=${req.method}\n`,
125
+ `[context-mcp] daemon: unknown ${address} for method=${req.method}\n`,
92
126
  )
93
127
  socket.write(
94
128
  JSON.stringify({
95
129
  ok: false,
96
- error: `unknown spawn-id: ${req.spawnId}`,
130
+ error: NOT_OWNED,
97
131
  } satisfies DaemonError) + '\n',
98
132
  )
99
133
  return
100
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
+ }
101
143
  process.stderr.write(
102
- `[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`,
103
145
  )
104
146
  try {
105
147
  let result: unknown
@@ -113,8 +155,41 @@ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry)
113
155
  result = await binding.provider.getThreadHistory(channelId, threadId, req.params)
114
156
  } else if (req.method === 'getChannelMembers') {
115
157
  result = await binding.provider.getChannelMembers(channelId)
116
- } else if (req.method === 'getChannelInfo') {
117
- 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)
118
193
  } else {
119
194
  socket.write(
120
195
  JSON.stringify({
@@ -135,6 +210,43 @@ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry)
135
210
  }
136
211
  }
137
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
+
138
250
  export async function callDaemon(sockPath: string, req: DaemonRequest): Promise<unknown> {
139
251
  return new Promise((resolve, reject) => {
140
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
+ })