@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.
- package/dist/bin.js +44 -4
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-ZY2GPHFX.js → chunk-IZ2JCKE4.js} +173 -14
- package/dist/chunk-IZ2JCKE4.js.map +1 -0
- package/dist/index.js +34 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/bin.ts +63 -9
- package/src/daemon-socket.test.ts +178 -18
- package/src/daemon-socket.ts +139 -12
- package/src/factory.test.ts +10 -0
- package/src/index.ts +3 -2
- package/src/integration.test.ts +99 -15
- package/src/mcp-server.test.ts +225 -21
- package/src/mcp-server.ts +87 -7
- package/src/socket-paths.test.ts +25 -0
- package/src/socket-paths.ts +29 -0
- package/src/spawn-registry.test.ts +14 -1
- package/src/spawn-registry.ts +29 -1
- package/src/types.ts +2 -0
- package/dist/chunk-ZY2GPHFX.js.map +0 -1
package/src/mcp-server.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
27
|
-
const server = buildContextMcpServer({
|
|
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
|
-
{
|
|
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: [
|
|
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({
|
|
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({
|
|
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<{
|
|
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
|
-
{
|
|
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({
|
|
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({
|
|
288
|
+
const server = buildContextMcpServer({
|
|
289
|
+
resolve: async () => makeProvider(),
|
|
290
|
+
})
|
|
144
291
|
const client = await connect(server)
|
|
145
|
-
const res = await client.callTool({
|
|
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
|
|
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
|
-
{
|
|
303
|
+
{
|
|
304
|
+
id: '@architect:hs',
|
|
305
|
+
name: 'architect',
|
|
306
|
+
is_agent: true,
|
|
307
|
+
agent_name: 'architect',
|
|
308
|
+
},
|
|
154
309
|
],
|
|
155
|
-
|
|
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({
|
|
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
|
-
{
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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('', {
|
|
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('', {
|
|
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
|
-
|
|
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
|
-
'
|
|
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.
|
|
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
|
-
|
|
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
|
})
|
package/src/spawn-registry.ts
CHANGED
|
@@ -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[]`. */
|