@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.
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3
3
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
4
4
  import { buildContextMcpServer } from './mcp-server.js'
5
- import type { TransportContextProvider } from '@zooid/core'
5
+ import type { TaskActions, TransportContextProvider } from '@zooid/core'
6
6
 
7
7
  function makeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
8
8
  return {
@@ -10,7 +10,18 @@ function makeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
10
10
  getRecentThreads: async () => ({ threads: [], has_more: false }),
11
11
  getThreadHistory: async () => ({ messages: [], has_more: false }),
12
12
  getChannelMembers: async () => [],
13
- getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
13
+ getRoomInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
14
+ getRooms: async () => [{ id: '!a:localhost', name: 'general', transport: 'matrix' }],
15
+ sendMessage: async () => ({ event_id: '$sent' }),
16
+ ...over,
17
+ }
18
+ }
19
+
20
+ function makeTasks(over: Partial<TaskActions> = {}): TaskActions {
21
+ return {
22
+ startTasks: async () => ({ results: [], notify: 'caller', delivery: 'd' }),
23
+ completeTask: async () => ({ status: 'recorded' }),
24
+ describeRole: async () => ({ is_task_assignee: false, can_start_task_threads: true }),
14
25
  ...over,
15
26
  }
16
27
  }
@@ -23,19 +34,121 @@ async function connect(server: ReturnType<typeof buildContextMcpServer>) {
23
34
  }
24
35
 
25
36
  describe('buildContextMcpServer', () => {
26
- it('lists exactly five tools with the spec names', async () => {
27
- const server = buildContextMcpServer({ resolve: async () => makeProvider() })
37
+ it('lists the read-only surface plus zooid_send_message and zooid_get_rooms when no task role is given', async () => {
38
+ const server = buildContextMcpServer({
39
+ resolve: async () => makeProvider(),
40
+ })
28
41
  const client = await connect(server)
29
42
  const list = await client.listTools()
30
43
  expect(list.tools.map((t) => t.name).sort()).toEqual([
31
- 'zooid_get_channel_info',
32
44
  'zooid_get_history',
33
45
  'zooid_get_members',
34
46
  'zooid_get_recent_threads',
47
+ 'zooid_get_room_info',
48
+ 'zooid_get_rooms',
35
49
  'zooid_get_thread_history',
50
+ 'zooid_send_message',
36
51
  ])
37
52
  })
38
53
 
54
+ it('renames the two tools that did not name Matrix primitives', async () => {
55
+ const server = buildContextMcpServer({ resolve: async () => makeProvider() })
56
+ const client = await connect(server)
57
+ const names = (await client.listTools()).tools.map((t) => t.name)
58
+ expect(names).toContain('zooid_get_room_info')
59
+ expect(names).not.toContain('zooid_get_channel_info')
60
+ expect(names).toContain('zooid_send_message')
61
+ expect(names).toContain('zooid_get_rooms')
62
+ })
63
+
64
+ it('registers start_task_threads but not complete_task for a non-assignee', async () => {
65
+ const server = buildContextMcpServer({
66
+ resolve: async () => makeProvider(),
67
+ resolveTasks: async () => makeTasks(),
68
+ role: { is_task_assignee: false, can_start_task_threads: true },
69
+ })
70
+ const names = (await (await connect(server)).listTools()).tools.map((t) => t.name)
71
+ expect(names).toContain('zooid_start_task_threads')
72
+ expect(names).not.toContain('zooid_complete_task')
73
+ })
74
+
75
+ it('registers complete_task but not start_task_threads for an assignee', async () => {
76
+ const server = buildContextMcpServer({
77
+ resolve: async () => makeProvider(),
78
+ resolveTasks: async () => makeTasks(),
79
+ role: { is_task_assignee: true, can_start_task_threads: false },
80
+ })
81
+ const names = (await (await connect(server)).listTools()).tools.map((t) => t.name)
82
+ expect(names).toContain('zooid_complete_task')
83
+ expect(names).not.toContain('zooid_start_task_threads')
84
+ })
85
+
86
+ it('omits both task tools when no role is resolved', async () => {
87
+ const server = buildContextMcpServer({
88
+ resolve: async () => makeProvider(),
89
+ resolveTasks: async () => makeTasks(),
90
+ })
91
+ const names = (await (await connect(server)).listTools()).tools.map((t) => t.name)
92
+ expect(names).not.toContain('zooid_complete_task')
93
+ expect(names).not.toContain('zooid_start_task_threads')
94
+ })
95
+
96
+ it('start_task_threads returns the delivery contract verbatim', async () => {
97
+ const server = buildContextMcpServer({
98
+ resolve: async () => makeProvider(),
99
+ resolveTasks: async () =>
100
+ makeTasks({
101
+ startTasks: async () => ({
102
+ results: [{ agent: 'reviewer', status: 'started', thread_id: '$t' }],
103
+ notify: 'caller',
104
+ delivery:
105
+ 'Each result returns to you as a new turn when that task completes. End your turn now — do not read the task thread to wait for it.',
106
+ }),
107
+ }),
108
+ role: { is_task_assignee: false, can_start_task_threads: true },
109
+ })
110
+ const client = await connect(server)
111
+ const res = await client.callTool({
112
+ name: 'zooid_start_task_threads',
113
+ arguments: { tasks: [{ agent: 'reviewer', prompt: 'review' }] },
114
+ })
115
+ const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text)
116
+ expect(payload.notify).toBe('caller')
117
+ expect(payload.delivery).toMatch(/End your turn now/)
118
+ })
119
+
120
+ it('send_message forwards room, thread_id and text', async () => {
121
+ const sent: unknown[] = []
122
+ const server = buildContextMcpServer({
123
+ resolve: async () =>
124
+ makeProvider({
125
+ sendMessage: async (input) => {
126
+ sent.push(input)
127
+ return { event_id: '$e', thread_id: '$t' }
128
+ },
129
+ }),
130
+ })
131
+ const client = await connect(server)
132
+ await client.callTool({
133
+ name: 'zooid_send_message',
134
+ arguments: { room: '!a:localhost', thread_id: '$t', text: 'noted' },
135
+ })
136
+ expect(sent).toEqual([{ room: '!a:localhost', thread_id: '$t', text: 'noted' }])
137
+ })
138
+
139
+ it('zooid_get_rooms returns the provider payload', async () => {
140
+ const server = buildContextMcpServer({
141
+ resolve: async () =>
142
+ makeProvider({
143
+ getRooms: async () => [{ id: '!a:localhost', name: 'general', transport: 'matrix' }],
144
+ }),
145
+ })
146
+ const client = await connect(server)
147
+ const res = await client.callTool({ name: 'zooid_get_rooms', arguments: {} })
148
+ const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text)
149
+ expect(payload).toEqual({ rooms: [{ id: '!a:localhost', name: 'general', transport: 'matrix' }] })
150
+ })
151
+
39
152
  it('zooid_get_history forwards limit + before and returns the page as text JSON', async () => {
40
153
  const calls: Array<{ limit?: number; before?: string }> = []
41
154
  const provider = makeProvider({
@@ -43,7 +156,13 @@ describe('buildContextMcpServer', () => {
43
156
  calls.push(opts)
44
157
  return {
45
158
  messages: [
46
- { id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false },
159
+ {
160
+ id: 'e1',
161
+ sender: 'alice',
162
+ text: 'hi',
163
+ timestamp: 'T',
164
+ is_agent: false,
165
+ },
47
166
  ],
48
167
  next_before: 'cursor-2',
49
168
  has_more: true,
@@ -59,7 +178,15 @@ describe('buildContextMcpServer', () => {
59
178
  expect(calls).toEqual([{ limit: 10, before: 'cursor-1' }])
60
179
  const text = (res.content as Array<{ type: string; text: string }>)[0].text
61
180
  expect(JSON.parse(text)).toEqual({
62
- messages: [{ id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false }],
181
+ messages: [
182
+ {
183
+ id: 'e1',
184
+ sender: 'alice',
185
+ text: 'hi',
186
+ timestamp: 'T',
187
+ is_agent: false,
188
+ },
189
+ ],
63
190
  next_before: 'cursor-2',
64
191
  has_more: true,
65
192
  })
@@ -75,7 +202,10 @@ describe('buildContextMcpServer', () => {
75
202
  })
76
203
  const server = buildContextMcpServer({ resolve: async () => provider })
77
204
  const client = await connect(server)
78
- await client.callTool({ name: 'zooid_get_history', arguments: { limit: 5000 } })
205
+ await client.callTool({
206
+ name: 'zooid_get_history',
207
+ arguments: { limit: 5000 },
208
+ })
79
209
  expect(calls[0].limit).toBe(200)
80
210
  })
81
211
 
@@ -112,19 +242,31 @@ describe('buildContextMcpServer', () => {
112
242
  })
113
243
  const server = buildContextMcpServer({ resolve: async () => provider })
114
244
  const client = await connect(server)
115
- const res = await client.callTool({ name: 'zooid_get_recent_threads', arguments: {} })
245
+ const res = await client.callTool({
246
+ name: 'zooid_get_recent_threads',
247
+ arguments: {},
248
+ })
116
249
  const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text)
117
250
  expect(payload.threads[0]).toMatchObject({ id: '$root', reply_count: 4 })
118
251
  })
119
252
 
120
253
  it('zooid_get_thread_history forwards thread_id and limit/before', async () => {
121
- const calls: Array<{ threadId: string; opts: { limit?: number; before?: string } }> = []
254
+ const calls: Array<{
255
+ threadId: string
256
+ opts: { limit?: number; before?: string }
257
+ }> = []
122
258
  const provider = makeProvider({
123
259
  getThreadHistory: async (_c, threadId, opts) => {
124
260
  calls.push({ threadId, opts })
125
261
  return {
126
262
  messages: [
127
- { id: '$root', sender: 'alice', text: 'root', timestamp: 'T', is_agent: false },
263
+ {
264
+ id: '$root',
265
+ sender: 'alice',
266
+ text: 'root',
267
+ timestamp: 'T',
268
+ is_agent: false,
269
+ },
128
270
  ],
129
271
  has_more: false,
130
272
  }
@@ -136,36 +278,64 @@ describe('buildContextMcpServer', () => {
136
278
  name: 'zooid_get_thread_history',
137
279
  arguments: { thread_id: '$root', limit: 10 },
138
280
  })
139
- expect(calls[0]).toEqual({ threadId: '$root', opts: { limit: 10, before: undefined } })
281
+ expect(calls[0]).toEqual({
282
+ threadId: '$root',
283
+ opts: { limit: 10, before: undefined },
284
+ })
140
285
  })
141
286
 
142
287
  it('zooid_get_thread_history surfaces a validation error when thread_id is missing', async () => {
143
- const server = buildContextMcpServer({ resolve: async () => makeProvider() })
288
+ const server = buildContextMcpServer({
289
+ resolve: async () => makeProvider(),
290
+ })
144
291
  const client = await connect(server)
145
- const res = await client.callTool({ name: 'zooid_get_thread_history', arguments: {} })
292
+ const res = await client.callTool({
293
+ name: 'zooid_get_thread_history',
294
+ arguments: {},
295
+ })
146
296
  expect(res.isError).toBe(true)
147
297
  })
148
298
 
149
- it('zooid_get_members and zooid_get_channel_info return the provider payload', async () => {
299
+ it('zooid_get_members and zooid_get_room_info return the provider payload', async () => {
150
300
  const provider = makeProvider({
151
301
  getChannelMembers: async () => [
152
302
  { id: '@alice:hs', name: 'alice', is_agent: false },
153
- { id: '@architect:hs', name: 'architect', is_agent: true, agent_name: 'architect' },
303
+ {
304
+ id: '@architect:hs',
305
+ name: 'architect',
306
+ is_agent: true,
307
+ agent_name: 'architect',
308
+ },
154
309
  ],
155
- getChannelInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
310
+ getRoomInfo: async () => ({
311
+ id: '!r:hs',
312
+ name: 'general',
313
+ transport: 'matrix',
314
+ }),
156
315
  })
157
316
  const server = buildContextMcpServer({ resolve: async () => provider })
158
317
  const client = await connect(server)
159
318
 
160
- const m = await client.callTool({ name: 'zooid_get_members', arguments: {} })
319
+ const m = await client.callTool({
320
+ name: 'zooid_get_members',
321
+ arguments: {},
322
+ })
161
323
  expect(JSON.parse((m.content as Array<{ text: string }>)[0].text)).toEqual({
162
324
  members: [
163
325
  { id: '@alice:hs', name: 'alice', is_agent: false },
164
- { id: '@architect:hs', name: 'architect', is_agent: true, agent_name: 'architect' },
326
+ {
327
+ id: '@architect:hs',
328
+ name: 'architect',
329
+ is_agent: true,
330
+ agent_name: 'architect',
331
+ },
165
332
  ],
166
333
  })
167
334
 
168
- const i = await client.callTool({ name: 'zooid_get_channel_info', arguments: {} })
335
+ const i = await client.callTool({
336
+ name: 'zooid_get_room_info',
337
+ arguments: {},
338
+ })
169
339
  expect(JSON.parse((i.content as Array<{ text: string }>)[0].text)).toEqual({
170
340
  id: '!r:hs',
171
341
  name: 'general',
@@ -180,7 +350,41 @@ describe('buildContextMcpServer', () => {
180
350
  },
181
351
  })
182
352
  const client = await connect(server)
183
- const res = await client.callTool({ name: 'zooid_get_history', arguments: {} })
353
+ const res = await client.callTool({
354
+ name: 'zooid_get_history',
355
+ arguments: {},
356
+ })
184
357
  expect(res.isError).toBe(true)
185
358
  })
359
+
360
+ it('exposes task writes only when the daemon supplies task actions and a matching role', async () => {
361
+ const calls: unknown[] = []
362
+ const server = buildContextMcpServer({
363
+ resolve: async () => makeProvider(),
364
+ resolveTasks: async () =>
365
+ makeTasks({
366
+ startTasks: async (_caller, input) => {
367
+ calls.push(input)
368
+ return {
369
+ results: [{ agent: 'worker', status: 'started', thread_id: '$task' }],
370
+ notify: 'caller',
371
+ delivery: 'd',
372
+ }
373
+ },
374
+ }),
375
+ role: { is_task_assignee: false, can_start_task_threads: true },
376
+ })
377
+ const client = await connect(server)
378
+ expect((await client.listTools()).tools.map((t) => t.name)).toContain(
379
+ 'zooid_start_task_threads',
380
+ )
381
+ const result = await client.callTool({
382
+ name: 'zooid_start_task_threads',
383
+ arguments: { tasks: [{ agent: 'worker', prompt: 'audit' }] },
384
+ })
385
+ expect(calls).toEqual([{ tasks: [{ agent: 'worker', prompt: 'audit' }], notify: 'caller' }])
386
+ expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toMatchObject({
387
+ results: [{ thread_id: '$task' }],
388
+ })
389
+ })
186
390
  })
package/src/mcp-server.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
- import type { TransportContextProvider } from '@zooid/core'
3
+ import type { TaskActions, TaskCallerRef, TaskRole, TransportContextProvider } from '@zooid/core'
4
4
 
5
5
  const MAX_LIMIT = 200
6
6
  const DEFAULT_LIMIT = 50
@@ -12,6 +12,21 @@ export interface BuildContextMcpServerOpts {
12
12
  * fake provider directly.
13
13
  */
14
14
  resolve: () => Promise<TransportContextProvider>
15
+ resolveTasks?: () => Promise<TaskActions>
16
+ /**
17
+ * What this session is, queried once before the server is built. Absent =
18
+ * neither task tool is registered — the safe direction for MCP, since the
19
+ * tools are additive and a spawn that can't reach the daemon can't
20
+ * usefully call them anyway ([[ZOD084]]).
21
+ */
22
+ role?: TaskRole
23
+ }
24
+ // The socket substitutes its authenticated spawn binding; models never supply an address.
25
+ const CALLER_FROM_BINDING: TaskCallerRef = {
26
+ agentName: '',
27
+ channelId: '',
28
+ threadRoot: '',
29
+ sessionKey: '',
15
30
  }
16
31
 
17
32
  export function buildContextMcpServer(opts: BuildContextMcpServerOpts): McpServer {
@@ -19,7 +34,7 @@ export function buildContextMcpServer(opts: BuildContextMcpServerOpts): McpServe
19
34
 
20
35
  server.tool(
21
36
  'zooid_get_history',
22
- "Read every message in the current room chronologically — top-level messages and all thread replies. Each message has an optional `thread_id` so the agent can group by thread. For a scan-the-room overview without reply noise, use `zooid_get_recent_threads` instead. Supports `limit` + `before` pagination.",
37
+ 'Read every message in the current room chronologically — top-level messages and all thread replies. Each message has an optional `thread_id` so the agent can group by thread. For a scan-the-room overview without reply noise, use `zooid_get_recent_threads` instead. Supports `limit` + `before` pagination.',
23
38
  {
24
39
  limit: z.number().int().positive().optional(),
25
40
  before: z.string().optional(),
@@ -27,11 +42,47 @@ export function buildContextMcpServer(opts: BuildContextMcpServerOpts): McpServe
27
42
  async ({ limit, before }) => {
28
43
  const provider = await opts.resolve()
29
44
  const clamped = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT)
30
- const page = await provider.getRoomHistory('', { limit: clamped, before })
45
+ const page = await provider.getRoomHistory('', {
46
+ limit: clamped,
47
+ before,
48
+ })
31
49
  return { content: [{ type: 'text', text: JSON.stringify(page) }] }
32
50
  },
33
51
  )
34
52
 
53
+ if (opts.resolveTasks && opts.role?.can_start_task_threads) {
54
+ server.tool(
55
+ 'zooid_start_task_threads',
56
+ 'Assign concurrent work to other agents in this room. Each task opens a separate thread. The return payload states how the result comes back — read `delivery` before deciding what to do next.',
57
+ {
58
+ tasks: z.array(z.object({ agent: z.string(), prompt: z.string() })).min(1),
59
+ notify: z.enum(['caller', 'none']).optional(),
60
+ },
61
+ async ({ tasks, notify }) => {
62
+ const out = await opts.resolveTasks!().then((actions) =>
63
+ actions.startTasks(CALLER_FROM_BINDING, {
64
+ tasks,
65
+ notify: notify ?? 'caller',
66
+ }),
67
+ )
68
+ return { content: [{ type: 'text', text: JSON.stringify(out) }] }
69
+ },
70
+ )
71
+ }
72
+ if (opts.resolveTasks && opts.role?.is_task_assignee) {
73
+ server.tool(
74
+ 'zooid_complete_task',
75
+ 'Record an explicit result for the delegated task you were assigned.',
76
+ { summary: z.string().min(1) },
77
+ async ({ summary }) => {
78
+ const out = await opts.resolveTasks!().then((actions) =>
79
+ actions.completeTask(CALLER_FROM_BINDING, { summary }),
80
+ )
81
+ return { content: [{ type: 'text', text: JSON.stringify(out) }] }
82
+ },
83
+ )
84
+ }
85
+
35
86
  server.tool(
36
87
  'zooid_get_recent_threads',
37
88
  "Scan-the-room overview: top-level messages and thread roots in the current room, newest first. Each entry has `reply_count` and `last_activity_at` so the agent can spot active conversations. Drill into a thread with `zooid_get_thread_history(thread_id)` where `thread_id` is the entry's `id`.",
@@ -42,14 +93,17 @@ export function buildContextMcpServer(opts: BuildContextMcpServerOpts): McpServe
42
93
  async ({ limit, before }) => {
43
94
  const provider = await opts.resolve()
44
95
  const clamped = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT)
45
- const page = await provider.getRecentThreads('', { limit: clamped, before })
96
+ const page = await provider.getRecentThreads('', {
97
+ limit: clamped,
98
+ before,
99
+ })
46
100
  return { content: [{ type: 'text', text: JSON.stringify(page) }] }
47
101
  },
48
102
  )
49
103
 
50
104
  server.tool(
51
105
  'zooid_get_thread_history',
52
- "Drill into a specific thread: the root message followed by all replies in chronological order. Pass the `thread_id` from a `zooid_get_recent_threads` entry or a `Message.thread_id` from `zooid_get_history`.",
106
+ 'Drill into a specific thread: the root message followed by all replies in chronological order. Pass the `thread_id` from a `zooid_get_recent_threads` entry or a `Message.thread_id` from `zooid_get_history`.',
53
107
  {
54
108
  thread_id: z.string(),
55
109
  limit: z.number().int().positive().optional(),
@@ -78,15 +132,41 @@ export function buildContextMcpServer(opts: BuildContextMcpServerOpts): McpServe
78
132
  )
79
133
 
80
134
  server.tool(
81
- 'zooid_get_channel_info',
135
+ 'zooid_get_room_info',
82
136
  'Describe the current room: id, display name, transport kind.',
83
137
  {},
84
138
  async () => {
85
139
  const provider = await opts.resolve()
86
- const info = await provider.getChannelInfo('')
140
+ const info = await provider.getRoomInfo('')
87
141
  return { content: [{ type: 'text', text: JSON.stringify(info) }] }
88
142
  },
89
143
  )
90
144
 
145
+ server.tool(
146
+ 'zooid_get_rooms',
147
+ 'List the rooms this agent is a member of. Valid targets for zooid_send_message.',
148
+ {},
149
+ async () => {
150
+ const provider = await opts.resolve()
151
+ const rooms = await provider.getRooms()
152
+ return { content: [{ type: 'text', text: JSON.stringify({ rooms }) }] }
153
+ },
154
+ )
155
+
156
+ server.tool(
157
+ 'zooid_send_message',
158
+ 'Post a message into a room or thread this agent is bound to. Fire-and-forget: no assignee, no completion tracking, no notify. Use zooid_start_task_threads instead when the intent is delegation.',
159
+ {
160
+ room: z.string(),
161
+ thread_id: z.string().optional(),
162
+ text: z.string(),
163
+ },
164
+ async ({ room, thread_id, text }) => {
165
+ const provider = await opts.resolve()
166
+ const result = await provider.sendMessage({ room, thread_id, text })
167
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] }
168
+ },
169
+ )
170
+
91
171
  return server
92
172
  }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { agentSocketPath, MAX_RUN_DIR, SUN_PATH_MAX } from './socket-paths.js'
3
+
4
+ describe('agentSocketPath', () => {
5
+ it('is stable, fixed-width, and safely within sun_path', () => {
6
+ const path = agentSocketPath({ runDir: '/data/run', agentName: 'zooid-assistant' })
7
+ expect(path).toBe(agentSocketPath({ runDir: '/data/run', agentName: 'zooid-assistant' }))
8
+ expect(path).toMatch(/^\/data\/run\/context-[0-9a-f]{12}\.sock$/)
9
+ expect(path.length).toBeLessThanOrEqual(SUN_PATH_MAX)
10
+ })
11
+
12
+ it('separates punctuation and case without spending extra path budget', () => {
13
+ const paths = ['Ops/Team #1', 'ops team 1', 'ops-team-1'].map((agentName) =>
14
+ agentSocketPath({ runDir: '/data/run', agentName }),
15
+ )
16
+ expect(new Set(paths).size).toBe(3)
17
+ expect(new Set(paths.map((path) => path.length)).size).toBe(1)
18
+ })
19
+
20
+ it('accepts the documented run-dir limit and rejects one byte beyond it', () => {
21
+ const ok = '/' + 'x'.repeat(MAX_RUN_DIR - 1)
22
+ expect(() => agentSocketPath({ runDir: ok, agentName: 'a' })).not.toThrow()
23
+ expect(() => agentSocketPath({ runDir: `${ok}x`, agentName: 'a' })).toThrow(/exceeds the \d+-byte limit/)
24
+ })
25
+ })
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { join } from 'node:path'
3
+
4
+ /** Maximum pathname bytes accepted by Unix-domain sockets, excluding its NUL. */
5
+ export const SUN_PATH_MAX = process.platform === 'darwin' ? 103 : 107
6
+
7
+ /** `context-` + twelve hex characters + `.sock`. */
8
+ const BASENAME_LEN = 25
9
+
10
+ /** Longest run directory which can hold one of this module's sockets. */
11
+ export const MAX_RUN_DIR = SUN_PATH_MAX - 1 - BASENAME_LEN
12
+
13
+ /**
14
+ * Return a deterministic, fixed-width socket pathname for an agent.
15
+ *
16
+ * Hashing keeps arbitrary agent names out of the Unix socket path and leaves a
17
+ * predictable amount of `sun_path` room for the daemon data directory.
18
+ */
19
+ export function agentSocketPath(opts: { runDir: string; agentName: string }): string {
20
+ if (opts.runDir.length > MAX_RUN_DIR) {
21
+ throw new Error(
22
+ `[context-mcp] run dir ${opts.runDir} (${opts.runDir.length} bytes) exceeds ` +
23
+ `the ${MAX_RUN_DIR}-byte limit — unix socket paths cap at ${SUN_PATH_MAX + 1} bytes. ` +
24
+ `Move the data dir closer to the filesystem root.`,
25
+ )
26
+ }
27
+ const hash = createHash('sha256').update(opts.agentName).digest('hex').slice(0, 12)
28
+ return join(opts.runDir, `context-${hash}.sock`)
29
+ }
@@ -7,7 +7,9 @@ const fakeProvider: TransportContextProvider = {
7
7
  getRecentThreads: async () => ({ threads: [], has_more: false }),
8
8
  getThreadHistory: async () => ({ messages: [], has_more: false }),
9
9
  getChannelMembers: async () => [],
10
- getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
10
+ getRoomInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
11
+ getRooms: async () => [],
12
+ sendMessage: async () => ({ event_id: '$sent' }),
11
13
  }
12
14
 
13
15
  describe('SpawnRegistry', () => {
@@ -55,4 +57,15 @@ describe('SpawnRegistry', () => {
55
57
  const r = new SpawnRegistry()
56
58
  expect(r.get('not-a-real-id')).toBeUndefined()
57
59
  })
60
+
61
+ it('resolves bindings by ACP session after linking without crossing agents', () => {
62
+ const r = new SpawnRegistry()
63
+ const a = r.register({ agentName: 'a', threadRef: { channelId: 'c', threadId: 't' }, sessionKey: 't', provider: fakeProvider })
64
+ const b = r.register({ agentName: 'b', threadRef: { channelId: 'c', threadId: 't' }, sessionKey: 't', provider: fakeProvider })
65
+ r.linkSession('a', 't', 'acp-a')
66
+ r.linkSession('b', 't', 'acp-b')
67
+ expect(r.getByAcpSession('acp-a')?.spawnId).toBe(a)
68
+ expect(r.getByAcpSession('acp-b')?.spawnId).toBe(b)
69
+ expect(r.getByAcpSession('orphan')).toBeUndefined()
70
+ })
58
71
  })
@@ -1,17 +1,22 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import type { SpawnBinding } from './types.js'
3
- import type { TransportContextProvider, ThreadRef } from '@zooid/core'
3
+ import type { TaskActions, TransportContextProvider, ThreadRef } from '@zooid/core'
4
4
 
5
5
  export class SpawnRegistry {
6
6
  private readonly bindings = new Map<string, SpawnBinding>()
7
+ private readonly spawnByAgentSession = new Map<string, string>()
8
+ private readonly spawnByAcpSession = new Map<string, string>()
9
+ private tasks: TaskActions | undefined
7
10
 
8
11
  register(input: {
9
12
  agentName: string
10
13
  threadRef: ThreadRef
11
14
  provider: TransportContextProvider
15
+ sessionKey?: string
12
16
  }): string {
13
17
  const spawnId = randomUUID()
14
18
  this.bindings.set(spawnId, { spawnId, ...input })
19
+ this.spawnByAgentSession.set(this.key(input.agentName, input.sessionKey ?? input.threadRef.threadId), spawnId)
15
20
  return spawnId
16
21
  }
17
22
 
@@ -21,5 +26,28 @@ export class SpawnRegistry {
21
26
 
22
27
  release(spawnId: string): void {
23
28
  this.bindings.delete(spawnId)
29
+ for (const [key, value] of this.spawnByAgentSession) {
30
+ if (value === spawnId) this.spawnByAgentSession.delete(key)
31
+ }
32
+ for (const [key, value] of this.spawnByAcpSession) {
33
+ if (value === spawnId) this.spawnByAcpSession.delete(key)
34
+ }
35
+ }
36
+ linkSession(agentName: string, sessionKey: string, acpSessionId: string): void {
37
+ const spawnId = this.spawnByAgentSession.get(this.key(agentName, sessionKey))
38
+ if (spawnId) this.spawnByAcpSession.set(acpSessionId, spawnId)
39
+ }
40
+ getByAcpSession(acpSessionId: string): SpawnBinding | undefined {
41
+ const spawnId = this.spawnByAcpSession.get(acpSessionId)
42
+ return spawnId ? this.bindings.get(spawnId) : undefined
43
+ }
44
+ setTaskActions(actions: TaskActions | undefined): void {
45
+ this.tasks = actions
46
+ }
47
+ get taskActions(): TaskActions | undefined {
48
+ return this.tasks
49
+ }
50
+ private key(agentName: string, sessionKey: string): string {
51
+ return `${agentName}::${sessionKey}`
24
52
  }
25
53
  }
package/src/types.ts CHANGED
@@ -9,6 +9,8 @@ export interface SpawnBinding {
9
9
  agentName: string
10
10
  threadRef: ThreadRef
11
11
  provider: TransportContextProvider
12
+ /** Exact ACP session key; the thread reference remains the context root. */
13
+ sessionKey?: string
12
14
  }
13
15
 
14
16
  /** Shape we pass into ACP `session/new mcpServers[]`. */