@zooid/context-mcp 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/bin.js +43 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-QQD76TLH.js +203 -0
- package/dist/chunk-QQD76TLH.js.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bin.ts +49 -0
- package/src/daemon-socket.test.ts +221 -0
- package/src/daemon-socket.ts +148 -0
- package/src/factory.test.ts +32 -0
- package/src/factory.ts +35 -0
- package/src/index.ts +6 -0
- package/src/integration.test.ts +138 -0
- package/src/mcp-server.test.ts +186 -0
- package/src/mcp-server.ts +92 -0
- package/src/spawn-registry.test.ts +58 -0
- package/src/spawn-registry.ts +25 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
3
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
4
|
+
import { buildContextMcpServer } from './mcp-server.js'
|
|
5
|
+
import type { TransportContextProvider } from '@zooid/core'
|
|
6
|
+
|
|
7
|
+
function makeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
|
|
8
|
+
return {
|
|
9
|
+
getRoomHistory: async () => ({ messages: [], has_more: false }),
|
|
10
|
+
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
11
|
+
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
12
|
+
getChannelMembers: async () => [],
|
|
13
|
+
getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
|
|
14
|
+
...over,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function connect(server: ReturnType<typeof buildContextMcpServer>) {
|
|
19
|
+
const [clientT, serverT] = InMemoryTransport.createLinkedPair()
|
|
20
|
+
const client = new Client({ name: 'test', version: '0.0.1' }, { capabilities: {} })
|
|
21
|
+
await Promise.all([server.connect(serverT), client.connect(clientT)])
|
|
22
|
+
return client
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('buildContextMcpServer', () => {
|
|
26
|
+
it('lists exactly five tools with the spec names', async () => {
|
|
27
|
+
const server = buildContextMcpServer({ resolve: async () => makeProvider() })
|
|
28
|
+
const client = await connect(server)
|
|
29
|
+
const list = await client.listTools()
|
|
30
|
+
expect(list.tools.map((t) => t.name).sort()).toEqual([
|
|
31
|
+
'zooid_get_channel_info',
|
|
32
|
+
'zooid_get_history',
|
|
33
|
+
'zooid_get_members',
|
|
34
|
+
'zooid_get_recent_threads',
|
|
35
|
+
'zooid_get_thread_history',
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('zooid_get_history forwards limit + before and returns the page as text JSON', async () => {
|
|
40
|
+
const calls: Array<{ limit?: number; before?: string }> = []
|
|
41
|
+
const provider = makeProvider({
|
|
42
|
+
getRoomHistory: async (_c, opts) => {
|
|
43
|
+
calls.push(opts)
|
|
44
|
+
return {
|
|
45
|
+
messages: [
|
|
46
|
+
{ id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false },
|
|
47
|
+
],
|
|
48
|
+
next_before: 'cursor-2',
|
|
49
|
+
has_more: true,
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
const server = buildContextMcpServer({ resolve: async () => provider })
|
|
54
|
+
const client = await connect(server)
|
|
55
|
+
const res = await client.callTool({
|
|
56
|
+
name: 'zooid_get_history',
|
|
57
|
+
arguments: { limit: 10, before: 'cursor-1' },
|
|
58
|
+
})
|
|
59
|
+
expect(calls).toEqual([{ limit: 10, before: 'cursor-1' }])
|
|
60
|
+
const text = (res.content as Array<{ type: string; text: string }>)[0].text
|
|
61
|
+
expect(JSON.parse(text)).toEqual({
|
|
62
|
+
messages: [{ id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false }],
|
|
63
|
+
next_before: 'cursor-2',
|
|
64
|
+
has_more: true,
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('clamps limit to max 200', async () => {
|
|
69
|
+
const calls: Array<{ limit?: number }> = []
|
|
70
|
+
const provider = makeProvider({
|
|
71
|
+
getRoomHistory: async (_c, opts) => {
|
|
72
|
+
calls.push(opts)
|
|
73
|
+
return { messages: [], has_more: false }
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
const server = buildContextMcpServer({ resolve: async () => provider })
|
|
77
|
+
const client = await connect(server)
|
|
78
|
+
await client.callTool({ name: 'zooid_get_history', arguments: { limit: 5000 } })
|
|
79
|
+
expect(calls[0].limit).toBe(200)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('defaults limit to 50 when omitted', async () => {
|
|
83
|
+
const calls: Array<{ limit?: number }> = []
|
|
84
|
+
const provider = makeProvider({
|
|
85
|
+
getRoomHistory: async (_c, opts) => {
|
|
86
|
+
calls.push(opts)
|
|
87
|
+
return { messages: [], has_more: false }
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
const server = buildContextMcpServer({ resolve: async () => provider })
|
|
91
|
+
const client = await connect(server)
|
|
92
|
+
await client.callTool({ name: 'zooid_get_history', arguments: {} })
|
|
93
|
+
expect(calls[0].limit).toBe(50)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('zooid_get_recent_threads returns the provider payload', async () => {
|
|
97
|
+
const provider = makeProvider({
|
|
98
|
+
getRecentThreads: async () => ({
|
|
99
|
+
threads: [
|
|
100
|
+
{
|
|
101
|
+
id: '$root',
|
|
102
|
+
sender: 'alice',
|
|
103
|
+
text: 'kickoff',
|
|
104
|
+
timestamp: 'T',
|
|
105
|
+
is_agent: false,
|
|
106
|
+
reply_count: 4,
|
|
107
|
+
last_activity_at: 'T2',
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
has_more: false,
|
|
111
|
+
}),
|
|
112
|
+
})
|
|
113
|
+
const server = buildContextMcpServer({ resolve: async () => provider })
|
|
114
|
+
const client = await connect(server)
|
|
115
|
+
const res = await client.callTool({ name: 'zooid_get_recent_threads', arguments: {} })
|
|
116
|
+
const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text)
|
|
117
|
+
expect(payload.threads[0]).toMatchObject({ id: '$root', reply_count: 4 })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('zooid_get_thread_history forwards thread_id and limit/before', async () => {
|
|
121
|
+
const calls: Array<{ threadId: string; opts: { limit?: number; before?: string } }> = []
|
|
122
|
+
const provider = makeProvider({
|
|
123
|
+
getThreadHistory: async (_c, threadId, opts) => {
|
|
124
|
+
calls.push({ threadId, opts })
|
|
125
|
+
return {
|
|
126
|
+
messages: [
|
|
127
|
+
{ id: '$root', sender: 'alice', text: 'root', timestamp: 'T', is_agent: false },
|
|
128
|
+
],
|
|
129
|
+
has_more: false,
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
const server = buildContextMcpServer({ resolve: async () => provider })
|
|
134
|
+
const client = await connect(server)
|
|
135
|
+
await client.callTool({
|
|
136
|
+
name: 'zooid_get_thread_history',
|
|
137
|
+
arguments: { thread_id: '$root', limit: 10 },
|
|
138
|
+
})
|
|
139
|
+
expect(calls[0]).toEqual({ threadId: '$root', opts: { limit: 10, before: undefined } })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('zooid_get_thread_history surfaces a validation error when thread_id is missing', async () => {
|
|
143
|
+
const server = buildContextMcpServer({ resolve: async () => makeProvider() })
|
|
144
|
+
const client = await connect(server)
|
|
145
|
+
const res = await client.callTool({ name: 'zooid_get_thread_history', arguments: {} })
|
|
146
|
+
expect(res.isError).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('zooid_get_members and zooid_get_channel_info return the provider payload', async () => {
|
|
150
|
+
const provider = makeProvider({
|
|
151
|
+
getChannelMembers: async () => [
|
|
152
|
+
{ id: '@alice:hs', name: 'alice', is_agent: false },
|
|
153
|
+
{ id: '@architect:hs', name: 'architect', is_agent: true, agent_name: 'architect' },
|
|
154
|
+
],
|
|
155
|
+
getChannelInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
|
|
156
|
+
})
|
|
157
|
+
const server = buildContextMcpServer({ resolve: async () => provider })
|
|
158
|
+
const client = await connect(server)
|
|
159
|
+
|
|
160
|
+
const m = await client.callTool({ name: 'zooid_get_members', arguments: {} })
|
|
161
|
+
expect(JSON.parse((m.content as Array<{ text: string }>)[0].text)).toEqual({
|
|
162
|
+
members: [
|
|
163
|
+
{ id: '@alice:hs', name: 'alice', is_agent: false },
|
|
164
|
+
{ id: '@architect:hs', name: 'architect', is_agent: true, agent_name: 'architect' },
|
|
165
|
+
],
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const i = await client.callTool({ name: 'zooid_get_channel_info', arguments: {} })
|
|
169
|
+
expect(JSON.parse((i.content as Array<{ text: string }>)[0].text)).toEqual({
|
|
170
|
+
id: '!r:hs',
|
|
171
|
+
name: 'general',
|
|
172
|
+
transport: 'matrix',
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns isError when the resolver throws (e.g. orphaned spawn-id)', async () => {
|
|
177
|
+
const server = buildContextMcpServer({
|
|
178
|
+
resolve: async () => {
|
|
179
|
+
throw new Error('unknown spawn')
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
const client = await connect(server)
|
|
183
|
+
const res = await client.callTool({ name: 'zooid_get_history', arguments: {} })
|
|
184
|
+
expect(res.isError).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
3
|
+
import type { TransportContextProvider } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
const MAX_LIMIT = 200
|
|
6
|
+
const DEFAULT_LIMIT = 50
|
|
7
|
+
|
|
8
|
+
export interface BuildContextMcpServerOpts {
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the provider for the current spawn. In production this calls
|
|
11
|
+
* back to the daemon over the Unix socket; in unit tests it returns a
|
|
12
|
+
* fake provider directly.
|
|
13
|
+
*/
|
|
14
|
+
resolve: () => Promise<TransportContextProvider>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildContextMcpServer(opts: BuildContextMcpServerOpts): McpServer {
|
|
18
|
+
const server = new McpServer({ name: 'zooid-context', version: '0.0.1' })
|
|
19
|
+
|
|
20
|
+
server.tool(
|
|
21
|
+
'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.",
|
|
23
|
+
{
|
|
24
|
+
limit: z.number().int().positive().optional(),
|
|
25
|
+
before: z.string().optional(),
|
|
26
|
+
},
|
|
27
|
+
async ({ limit, before }) => {
|
|
28
|
+
const provider = await opts.resolve()
|
|
29
|
+
const clamped = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT)
|
|
30
|
+
const page = await provider.getRoomHistory('', { limit: clamped, before })
|
|
31
|
+
return { content: [{ type: 'text', text: JSON.stringify(page) }] }
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
server.tool(
|
|
36
|
+
'zooid_get_recent_threads',
|
|
37
|
+
"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`.",
|
|
38
|
+
{
|
|
39
|
+
limit: z.number().int().positive().optional(),
|
|
40
|
+
before: z.string().optional(),
|
|
41
|
+
},
|
|
42
|
+
async ({ limit, before }) => {
|
|
43
|
+
const provider = await opts.resolve()
|
|
44
|
+
const clamped = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT)
|
|
45
|
+
const page = await provider.getRecentThreads('', { limit: clamped, before })
|
|
46
|
+
return { content: [{ type: 'text', text: JSON.stringify(page) }] }
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
server.tool(
|
|
51
|
+
'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`.",
|
|
53
|
+
{
|
|
54
|
+
thread_id: z.string(),
|
|
55
|
+
limit: z.number().int().positive().optional(),
|
|
56
|
+
before: z.string().optional(),
|
|
57
|
+
},
|
|
58
|
+
async ({ thread_id, limit, before }) => {
|
|
59
|
+
const provider = await opts.resolve()
|
|
60
|
+
const clamped = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT)
|
|
61
|
+
const page = await provider.getThreadHistory('', thread_id, {
|
|
62
|
+
limit: clamped,
|
|
63
|
+
before,
|
|
64
|
+
})
|
|
65
|
+
return { content: [{ type: 'text', text: JSON.stringify(page) }] }
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
server.tool(
|
|
70
|
+
'zooid_get_members',
|
|
71
|
+
'List the humans and agents in the current room.',
|
|
72
|
+
{},
|
|
73
|
+
async () => {
|
|
74
|
+
const provider = await opts.resolve()
|
|
75
|
+
const members = await provider.getChannelMembers('')
|
|
76
|
+
return { content: [{ type: 'text', text: JSON.stringify({ members }) }] }
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
server.tool(
|
|
81
|
+
'zooid_get_channel_info',
|
|
82
|
+
'Describe the current room: id, display name, transport kind.',
|
|
83
|
+
{},
|
|
84
|
+
async () => {
|
|
85
|
+
const provider = await opts.resolve()
|
|
86
|
+
const info = await provider.getChannelInfo('')
|
|
87
|
+
return { content: [{ type: 'text', text: JSON.stringify(info) }] }
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return server
|
|
92
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { SpawnRegistry } from './spawn-registry.js'
|
|
3
|
+
import type { TransportContextProvider } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
const fakeProvider: TransportContextProvider = {
|
|
6
|
+
getRoomHistory: async () => ({ messages: [], has_more: false }),
|
|
7
|
+
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
8
|
+
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
9
|
+
getChannelMembers: async () => [],
|
|
10
|
+
getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('SpawnRegistry', () => {
|
|
14
|
+
it('register() returns a unique spawn-id', () => {
|
|
15
|
+
const r = new SpawnRegistry()
|
|
16
|
+
const a = r.register({
|
|
17
|
+
agentName: 'architect',
|
|
18
|
+
threadRef: { channelId: '!room:hs', threadId: '$root' },
|
|
19
|
+
provider: fakeProvider,
|
|
20
|
+
})
|
|
21
|
+
const b = r.register({
|
|
22
|
+
agentName: 'architect',
|
|
23
|
+
threadRef: { channelId: '!room:hs', threadId: '$root' },
|
|
24
|
+
provider: fakeProvider,
|
|
25
|
+
})
|
|
26
|
+
expect(a).not.toEqual(b)
|
|
27
|
+
expect(a).toMatch(/^[a-f0-9-]{36}$/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('get() returns the binding stored under the spawn-id', () => {
|
|
31
|
+
const r = new SpawnRegistry()
|
|
32
|
+
const spawnId = r.register({
|
|
33
|
+
agentName: 'architect',
|
|
34
|
+
threadRef: { channelId: '!room:hs', threadId: '$root' },
|
|
35
|
+
provider: fakeProvider,
|
|
36
|
+
})
|
|
37
|
+
const b = r.get(spawnId)
|
|
38
|
+
expect(b?.agentName).toBe('architect')
|
|
39
|
+
expect(b?.threadRef.channelId).toBe('!room:hs')
|
|
40
|
+
expect(b?.provider).toBe(fakeProvider)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('release() removes the binding', () => {
|
|
44
|
+
const r = new SpawnRegistry()
|
|
45
|
+
const spawnId = r.register({
|
|
46
|
+
agentName: 'a',
|
|
47
|
+
threadRef: { channelId: 'c', threadId: 't' },
|
|
48
|
+
provider: fakeProvider,
|
|
49
|
+
})
|
|
50
|
+
r.release(spawnId)
|
|
51
|
+
expect(r.get(spawnId)).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('get() returns undefined for unknown spawn-ids', () => {
|
|
55
|
+
const r = new SpawnRegistry()
|
|
56
|
+
expect(r.get('not-a-real-id')).toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { SpawnBinding } from './types.js'
|
|
3
|
+
import type { TransportContextProvider, ThreadRef } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
export class SpawnRegistry {
|
|
6
|
+
private readonly bindings = new Map<string, SpawnBinding>()
|
|
7
|
+
|
|
8
|
+
register(input: {
|
|
9
|
+
agentName: string
|
|
10
|
+
threadRef: ThreadRef
|
|
11
|
+
provider: TransportContextProvider
|
|
12
|
+
}): string {
|
|
13
|
+
const spawnId = randomUUID()
|
|
14
|
+
this.bindings.set(spawnId, { spawnId, ...input })
|
|
15
|
+
return spawnId
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(spawnId: string): SpawnBinding | undefined {
|
|
19
|
+
return this.bindings.get(spawnId)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
release(spawnId: string): void {
|
|
23
|
+
this.bindings.delete(spawnId)
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TransportContextProvider, ThreadRef } from '@zooid/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Daemon-internal record keyed by spawn-id. One per ACP session that has
|
|
5
|
+
* a TransportContextProvider attached.
|
|
6
|
+
*/
|
|
7
|
+
export interface SpawnBinding {
|
|
8
|
+
spawnId: string
|
|
9
|
+
agentName: string
|
|
10
|
+
threadRef: ThreadRef
|
|
11
|
+
provider: TransportContextProvider
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Shape we pass into ACP `session/new mcpServers[]`. */
|
|
15
|
+
export interface ZooidContextServerSpec {
|
|
16
|
+
name: 'zooid-context'
|
|
17
|
+
command: string
|
|
18
|
+
args: string[]
|
|
19
|
+
env: Array<{ name: string; value: string }>
|
|
20
|
+
}
|