@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/dist/index.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
|
+
SUN_PATH_MAX,
|
|
3
|
+
agentSocketPath,
|
|
2
4
|
buildContextMcpServer,
|
|
3
5
|
callDaemon,
|
|
6
|
+
startAgentSocketServers,
|
|
4
7
|
startDaemonSocketServer
|
|
5
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-IZ2JCKE4.js";
|
|
6
9
|
|
|
7
10
|
// src/spawn-registry.ts
|
|
8
11
|
import { randomUUID } from "crypto";
|
|
9
12
|
var SpawnRegistry = class {
|
|
10
13
|
bindings = /* @__PURE__ */ new Map();
|
|
14
|
+
spawnByAgentSession = /* @__PURE__ */ new Map();
|
|
15
|
+
spawnByAcpSession = /* @__PURE__ */ new Map();
|
|
16
|
+
tasks;
|
|
11
17
|
register(input) {
|
|
12
18
|
const spawnId = randomUUID();
|
|
13
19
|
this.bindings.set(spawnId, { spawnId, ...input });
|
|
20
|
+
this.spawnByAgentSession.set(this.key(input.agentName, input.sessionKey ?? input.threadRef.threadId), spawnId);
|
|
14
21
|
return spawnId;
|
|
15
22
|
}
|
|
16
23
|
get(spawnId) {
|
|
@@ -18,6 +25,29 @@ var SpawnRegistry = class {
|
|
|
18
25
|
}
|
|
19
26
|
release(spawnId) {
|
|
20
27
|
this.bindings.delete(spawnId);
|
|
28
|
+
for (const [key, value] of this.spawnByAgentSession) {
|
|
29
|
+
if (value === spawnId) this.spawnByAgentSession.delete(key);
|
|
30
|
+
}
|
|
31
|
+
for (const [key, value] of this.spawnByAcpSession) {
|
|
32
|
+
if (value === spawnId) this.spawnByAcpSession.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
linkSession(agentName, sessionKey, acpSessionId) {
|
|
36
|
+
const spawnId = this.spawnByAgentSession.get(this.key(agentName, sessionKey));
|
|
37
|
+
if (spawnId) this.spawnByAcpSession.set(acpSessionId, spawnId);
|
|
38
|
+
}
|
|
39
|
+
getByAcpSession(acpSessionId) {
|
|
40
|
+
const spawnId = this.spawnByAcpSession.get(acpSessionId);
|
|
41
|
+
return spawnId ? this.bindings.get(spawnId) : void 0;
|
|
42
|
+
}
|
|
43
|
+
setTaskActions(actions) {
|
|
44
|
+
this.tasks = actions;
|
|
45
|
+
}
|
|
46
|
+
get taskActions() {
|
|
47
|
+
return this.tasks;
|
|
48
|
+
}
|
|
49
|
+
key(agentName, sessionKey) {
|
|
50
|
+
return `${agentName}::${sessionKey}`;
|
|
21
51
|
}
|
|
22
52
|
};
|
|
23
53
|
|
|
@@ -63,11 +93,14 @@ export {
|
|
|
63
93
|
CONTEXT_CONTAINER_BIN,
|
|
64
94
|
CONTEXT_CONTAINER_BIN_DIR,
|
|
65
95
|
CONTEXT_CONTAINER_SOCK,
|
|
96
|
+
SUN_PATH_MAX,
|
|
66
97
|
SpawnRegistry,
|
|
98
|
+
agentSocketPath,
|
|
67
99
|
buildContextMcpServer,
|
|
68
100
|
buildContextServerSpec,
|
|
69
101
|
callDaemon,
|
|
70
102
|
contextContainerMounts,
|
|
103
|
+
startAgentSocketServers,
|
|
71
104
|
startDaemonSocketServer
|
|
72
105
|
};
|
|
73
106
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/spawn-registry.ts","../src/factory.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport type { SpawnBinding } from './types.js'\nimport type { TransportContextProvider, ThreadRef } from '@zooid/core'\n\nexport class SpawnRegistry {\n private readonly bindings = new Map<string, SpawnBinding>()\n\n register(input: {\n agentName: string\n threadRef: ThreadRef\n provider: TransportContextProvider\n }): string {\n const spawnId = randomUUID()\n this.bindings.set(spawnId, { spawnId, ...input })\n return spawnId\n }\n\n get(spawnId: string): SpawnBinding | undefined {\n return this.bindings.get(spawnId)\n }\n\n release(spawnId: string): void {\n this.bindings.delete(spawnId)\n }\n}\n","import { createRequire } from 'node:module'\nimport { dirname } from 'node:path'\nimport type { AcpMount } from '@zooid/core'\nimport type { ZooidContextServerSpec } from './types.js'\n\n/**\n * Resolve `dist/bin.js` via Node's runtime module resolver rather than\n * `import.meta.url`. tsup bundles this file into the CLI's own chunk, so\n * `import.meta.url` at runtime points to the CLI bundle — not this\n * package's dist. `createRequire` walks the runtime `node_modules` tree\n * instead and finds the package wherever it actually lives.\n */\nfunction resolveDefaultBin(): string {\n const req = createRequire(import.meta.url)\n // Resolve via the dedicated `./bin` export → `./dist/bin.js`. Avoids the\n // exports-restriction surprise of importing `./package.json`.\n return req.resolve('@zooid/context-mcp/bin')\n}\n\nlet cachedDefaultBin: string | null = null\nfunction getDefaultBin(): string {\n if (!cachedDefaultBin) cachedDefaultBin = resolveDefaultBin()\n return cachedDefaultBin\n}\n\n/**\n * Fixed container-side paths for the containerized MCP spec. The daemon\n * bind-mounts the host `dist/` here (ro) and the daemon socket here (rw) so\n * opencode can spawn the zooid-context MCP subprocess inside its own container.\n */\nexport const CONTEXT_CONTAINER_BIN_DIR = '/zooid/context-mcp'\nexport const CONTEXT_CONTAINER_BIN = `${CONTEXT_CONTAINER_BIN_DIR}/bin.js`\nexport const CONTEXT_CONTAINER_SOCK = '/zooid/context.sock'\n\n/**\n * The two bind-mounts a containerized, context-enabled agent needs. The bin dir\n * is read-only (self-contained bundle); the socket is read-write (the MCP\n * subprocess connect()s to it). Host sources default to the resolved package\n * dist and the passed daemon socket path.\n */\nexport function contextContainerMounts(opts: {\n sockPath: string\n binPath?: string\n}): AcpMount[] {\n const binFile = opts.binPath ?? getDefaultBin()\n return [\n { path: dirname(binFile), target: CONTEXT_CONTAINER_BIN_DIR, mode: 'ro' },\n { path: opts.sockPath, target: CONTEXT_CONTAINER_SOCK, mode: 'rw' },\n ]\n}\n\nexport function buildContextServerSpec(opts: {\n spawnId: string\n sockPath: string\n binPath?: string\n /**\n * When set, emit a spec that resolves INSIDE the agent container: bare `node`\n * (image PATH), the bind-mounted bin path, and the bind-mounted socket path.\n * The host `sockPath`/`binPath` still feed `contextContainerMounts`, but do\n * not appear in the emitted command. Absent → host-runtime spec (unchanged).\n */\n containerize?: boolean\n}): ZooidContextServerSpec {\n if (opts.containerize) {\n return {\n name: 'zooid-context',\n command: 'node',\n args: [CONTEXT_CONTAINER_BIN, '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: CONTEXT_CONTAINER_SOCK }],\n }\n }\n return {\n name: 'zooid-context',\n command: process.execPath,\n args: [opts.binPath ?? getDefaultBin(), '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: opts.sockPath }],\n }\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/spawn-registry.ts","../src/factory.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport type { SpawnBinding } from './types.js'\nimport type { TaskActions, TransportContextProvider, ThreadRef } from '@zooid/core'\n\nexport class SpawnRegistry {\n private readonly bindings = new Map<string, SpawnBinding>()\n private readonly spawnByAgentSession = new Map<string, string>()\n private readonly spawnByAcpSession = new Map<string, string>()\n private tasks: TaskActions | undefined\n\n register(input: {\n agentName: string\n threadRef: ThreadRef\n provider: TransportContextProvider\n sessionKey?: string\n }): string {\n const spawnId = randomUUID()\n this.bindings.set(spawnId, { spawnId, ...input })\n this.spawnByAgentSession.set(this.key(input.agentName, input.sessionKey ?? input.threadRef.threadId), spawnId)\n return spawnId\n }\n\n get(spawnId: string): SpawnBinding | undefined {\n return this.bindings.get(spawnId)\n }\n\n release(spawnId: string): void {\n this.bindings.delete(spawnId)\n for (const [key, value] of this.spawnByAgentSession) {\n if (value === spawnId) this.spawnByAgentSession.delete(key)\n }\n for (const [key, value] of this.spawnByAcpSession) {\n if (value === spawnId) this.spawnByAcpSession.delete(key)\n }\n }\n linkSession(agentName: string, sessionKey: string, acpSessionId: string): void {\n const spawnId = this.spawnByAgentSession.get(this.key(agentName, sessionKey))\n if (spawnId) this.spawnByAcpSession.set(acpSessionId, spawnId)\n }\n getByAcpSession(acpSessionId: string): SpawnBinding | undefined {\n const spawnId = this.spawnByAcpSession.get(acpSessionId)\n return spawnId ? this.bindings.get(spawnId) : undefined\n }\n setTaskActions(actions: TaskActions | undefined): void {\n this.tasks = actions\n }\n get taskActions(): TaskActions | undefined {\n return this.tasks\n }\n private key(agentName: string, sessionKey: string): string {\n return `${agentName}::${sessionKey}`\n }\n}\n","import { createRequire } from 'node:module'\nimport { dirname } from 'node:path'\nimport type { AcpMount } from '@zooid/core'\nimport type { ZooidContextServerSpec } from './types.js'\n\n/**\n * Resolve `dist/bin.js` via Node's runtime module resolver rather than\n * `import.meta.url`. tsup bundles this file into the CLI's own chunk, so\n * `import.meta.url` at runtime points to the CLI bundle — not this\n * package's dist. `createRequire` walks the runtime `node_modules` tree\n * instead and finds the package wherever it actually lives.\n */\nfunction resolveDefaultBin(): string {\n const req = createRequire(import.meta.url)\n // Resolve via the dedicated `./bin` export → `./dist/bin.js`. Avoids the\n // exports-restriction surprise of importing `./package.json`.\n return req.resolve('@zooid/context-mcp/bin')\n}\n\nlet cachedDefaultBin: string | null = null\nfunction getDefaultBin(): string {\n if (!cachedDefaultBin) cachedDefaultBin = resolveDefaultBin()\n return cachedDefaultBin\n}\n\n/**\n * Fixed container-side paths for the containerized MCP spec. The daemon\n * bind-mounts the host `dist/` here (ro) and the daemon socket here (rw) so\n * opencode can spawn the zooid-context MCP subprocess inside its own container.\n */\nexport const CONTEXT_CONTAINER_BIN_DIR = '/zooid/context-mcp'\nexport const CONTEXT_CONTAINER_BIN = `${CONTEXT_CONTAINER_BIN_DIR}/bin.js`\nexport const CONTEXT_CONTAINER_SOCK = '/zooid/context.sock'\n\n/**\n * The two bind-mounts a containerized, context-enabled agent needs. The bin dir\n * is read-only (self-contained bundle); the socket is read-write (the MCP\n * subprocess connect()s to it). Host sources default to the resolved package\n * dist and the passed daemon socket path.\n */\nexport function contextContainerMounts(opts: {\n sockPath: string\n binPath?: string\n}): AcpMount[] {\n const binFile = opts.binPath ?? getDefaultBin()\n return [\n { path: dirname(binFile), target: CONTEXT_CONTAINER_BIN_DIR, mode: 'ro' },\n { path: opts.sockPath, target: CONTEXT_CONTAINER_SOCK, mode: 'rw' },\n ]\n}\n\nexport function buildContextServerSpec(opts: {\n spawnId: string\n sockPath: string\n binPath?: string\n /**\n * When set, emit a spec that resolves INSIDE the agent container: bare `node`\n * (image PATH), the bind-mounted bin path, and the bind-mounted socket path.\n * The host `sockPath`/`binPath` still feed `contextContainerMounts`, but do\n * not appear in the emitted command. Absent → host-runtime spec (unchanged).\n */\n containerize?: boolean\n}): ZooidContextServerSpec {\n if (opts.containerize) {\n return {\n name: 'zooid-context',\n command: 'node',\n args: [CONTEXT_CONTAINER_BIN, '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: CONTEXT_CONTAINER_SOCK }],\n }\n }\n return {\n name: 'zooid-context',\n command: process.execPath,\n args: [opts.binPath ?? getDefaultBin(), '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: opts.sockPath }],\n }\n}\n"],"mappings":";;;;;;;;;;AAAA,SAAS,kBAAkB;AAIpB,IAAM,gBAAN,MAAoB;AAAA,EACR,WAAW,oBAAI,IAA0B;AAAA,EACzC,sBAAsB,oBAAI,IAAoB;AAAA,EAC9C,oBAAoB,oBAAI,IAAoB;AAAA,EACrD;AAAA,EAER,SAAS,OAKE;AACT,UAAM,UAAU,WAAW;AAC3B,SAAK,SAAS,IAAI,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC;AAChD,SAAK,oBAAoB,IAAI,KAAK,IAAI,MAAM,WAAW,MAAM,cAAc,MAAM,UAAU,QAAQ,GAAG,OAAO;AAC7G,WAAO;AAAA,EACT;AAAA,EAEA,IAAI,SAA2C;AAC7C,WAAO,KAAK,SAAS,IAAI,OAAO;AAAA,EAClC;AAAA,EAEA,QAAQ,SAAuB;AAC7B,SAAK,SAAS,OAAO,OAAO;AAC5B,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,qBAAqB;AACnD,UAAI,UAAU,QAAS,MAAK,oBAAoB,OAAO,GAAG;AAAA,IAC5D;AACA,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,mBAAmB;AACjD,UAAI,UAAU,QAAS,MAAK,kBAAkB,OAAO,GAAG;AAAA,IAC1D;AAAA,EACF;AAAA,EACA,YAAY,WAAmB,YAAoB,cAA4B;AAC7E,UAAM,UAAU,KAAK,oBAAoB,IAAI,KAAK,IAAI,WAAW,UAAU,CAAC;AAC5E,QAAI,QAAS,MAAK,kBAAkB,IAAI,cAAc,OAAO;AAAA,EAC/D;AAAA,EACA,gBAAgB,cAAgD;AAC9D,UAAM,UAAU,KAAK,kBAAkB,IAAI,YAAY;AACvD,WAAO,UAAU,KAAK,SAAS,IAAI,OAAO,IAAI;AAAA,EAChD;AAAA,EACA,eAAe,SAAwC;AACrD,SAAK,QAAQ;AAAA,EACf;AAAA,EACA,IAAI,cAAuC;AACzC,WAAO,KAAK;AAAA,EACd;AAAA,EACQ,IAAI,WAAmB,YAA4B;AACzD,WAAO,GAAG,SAAS,KAAK,UAAU;AAAA,EACpC;AACF;;;ACpDA,SAAS,qBAAqB;AAC9B,SAAS,eAAe;AAWxB,SAAS,oBAA4B;AACnC,QAAM,MAAM,cAAc,YAAY,GAAG;AAGzC,SAAO,IAAI,QAAQ,wBAAwB;AAC7C;AAEA,IAAI,mBAAkC;AACtC,SAAS,gBAAwB;AAC/B,MAAI,CAAC,iBAAkB,oBAAmB,kBAAkB;AAC5D,SAAO;AACT;AAOO,IAAM,4BAA4B;AAClC,IAAM,wBAAwB,GAAG,yBAAyB;AAC1D,IAAM,yBAAyB;AAQ/B,SAAS,uBAAuB,MAGxB;AACb,QAAM,UAAU,KAAK,WAAW,cAAc;AAC9C,SAAO;AAAA,IACL,EAAE,MAAM,QAAQ,OAAO,GAAG,QAAQ,2BAA2B,MAAM,KAAK;AAAA,IACxE,EAAE,MAAM,KAAK,UAAU,QAAQ,wBAAwB,MAAM,KAAK;AAAA,EACpE;AACF;AAEO,SAAS,uBAAuB,MAWZ;AACzB,MAAI,KAAK,cAAc;AACrB,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB,cAAc,KAAK,OAAO;AAAA,MACxD,KAAK,CAAC,EAAE,MAAM,qBAAqB,OAAO,uBAAuB,CAAC;AAAA,IACpE;AAAA,EACF;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS,QAAQ;AAAA,IACjB,MAAM,CAAC,KAAK,WAAW,cAAc,GAAG,cAAc,KAAK,OAAO;AAAA,IAClE,KAAK,CAAC,EAAE,MAAM,qBAAqB,OAAO,KAAK,SAAS,CAAC;AAAA,EAC3D;AACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/context-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Daemon-side stdio MCP server + per-spawn registry that exposes zooid_get_history / zooid_get_members / zooid_get_channel_info to ACP shims, backed by each transport's TransportContextProvider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
33
33
|
"zod": "^3.23.0",
|
|
34
|
-
"@zooid/core": "^0.
|
|
34
|
+
"@zooid/core": "^0.14.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^22.0.0",
|
package/src/bin.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
3
|
import { buildContextMcpServer } from './mcp-server.js'
|
|
4
4
|
import { callDaemon } from './daemon-socket.js'
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
StartTasksOutput,
|
|
7
|
+
CompleteTaskOutput,
|
|
8
|
+
TaskActions,
|
|
9
|
+
TaskRole,
|
|
10
|
+
TransportContextProvider,
|
|
11
|
+
} from '@zooid/core'
|
|
6
12
|
|
|
7
13
|
const spawnIdIdx = process.argv.indexOf('--spawn-id')
|
|
8
14
|
const spawnId = spawnIdIdx >= 0 ? process.argv[spawnIdIdx + 1] : undefined
|
|
@@ -35,15 +41,63 @@ const remoteProvider: TransportContextProvider = {
|
|
|
35
41
|
params: { ...(opts ?? {}), threadId } as Record<string, unknown>,
|
|
36
42
|
}) as Promise<Awaited<ReturnType<TransportContextProvider['getThreadHistory']>>>,
|
|
37
43
|
getChannelMembers: () =>
|
|
38
|
-
callDaemon(sockPath, {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
callDaemon(sockPath, {
|
|
45
|
+
spawnId,
|
|
46
|
+
method: 'getChannelMembers',
|
|
47
|
+
params: {},
|
|
48
|
+
}) as Promise<Awaited<ReturnType<TransportContextProvider['getChannelMembers']>>>,
|
|
49
|
+
getRoomInfo: () =>
|
|
50
|
+
callDaemon(sockPath, {
|
|
51
|
+
spawnId,
|
|
52
|
+
method: 'getRoomInfo',
|
|
53
|
+
params: {},
|
|
54
|
+
}) as Promise<Awaited<ReturnType<TransportContextProvider['getRoomInfo']>>>,
|
|
55
|
+
getRooms: () =>
|
|
56
|
+
callDaemon(sockPath, {
|
|
57
|
+
spawnId,
|
|
58
|
+
method: 'getRooms',
|
|
59
|
+
params: {},
|
|
60
|
+
}) as Promise<Awaited<ReturnType<TransportContextProvider['getRooms']>>>,
|
|
61
|
+
sendMessage: (input) =>
|
|
62
|
+
callDaemon(sockPath, {
|
|
63
|
+
spawnId,
|
|
64
|
+
method: 'sendMessage',
|
|
65
|
+
params: input as unknown as Record<string, unknown>,
|
|
66
|
+
}) as Promise<Awaited<ReturnType<TransportContextProvider['sendMessage']>>>,
|
|
45
67
|
}
|
|
46
68
|
|
|
47
|
-
const
|
|
69
|
+
const remoteTasks: TaskActions = {
|
|
70
|
+
startTasks: (_caller, input) =>
|
|
71
|
+
callDaemon(sockPath, {
|
|
72
|
+
spawnId,
|
|
73
|
+
method: 'startTasks',
|
|
74
|
+
params: input as unknown as Record<string, unknown>,
|
|
75
|
+
}) as Promise<StartTasksOutput>,
|
|
76
|
+
completeTask: (_caller, input) =>
|
|
77
|
+
callDaemon(sockPath, {
|
|
78
|
+
spawnId,
|
|
79
|
+
method: 'completeTask',
|
|
80
|
+
params: input as unknown as Record<string, unknown>,
|
|
81
|
+
}) as Promise<CompleteTaskOutput>,
|
|
82
|
+
describeRole: () =>
|
|
83
|
+
callDaemon(sockPath, {
|
|
84
|
+
spawnId,
|
|
85
|
+
method: 'describeRole',
|
|
86
|
+
params: {},
|
|
87
|
+
}) as Promise<TaskRole>,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A failed role query yields undefined, which registers neither task tool —
|
|
91
|
+
// the safe direction for MCP: the tools are additive, and a spawn that
|
|
92
|
+
// cannot reach the daemon cannot usefully call them anyway ([[ZOD084]]).
|
|
93
|
+
const role = await callDaemon(sockPath, { spawnId, method: 'describeRole', params: {} })
|
|
94
|
+
.then((r) => r as TaskRole)
|
|
95
|
+
.catch(() => undefined)
|
|
96
|
+
|
|
97
|
+
const server = buildContextMcpServer({
|
|
98
|
+
resolve: async () => remoteProvider,
|
|
99
|
+
resolveTasks: async () => remoteTasks,
|
|
100
|
+
role,
|
|
101
|
+
})
|
|
48
102
|
await server.connect(new StdioServerTransport())
|
|
49
103
|
process.stderr.write(`zooid-context-mcp: ready (spawnId=${spawnId})\n`)
|
|
@@ -2,9 +2,10 @@ import { describe, it, expect, afterEach } from 'vitest'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { createConnection } from 'node:net'
|
|
5
6
|
import { SpawnRegistry } from './spawn-registry.js'
|
|
6
7
|
import { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
|
|
7
|
-
import type { TransportContextProvider } from '@zooid/core'
|
|
8
|
+
import type { TaskActions, TransportContextProvider } from '@zooid/core'
|
|
8
9
|
|
|
9
10
|
function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
|
|
10
11
|
return {
|
|
@@ -12,7 +13,18 @@ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
|
|
|
12
13
|
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
13
14
|
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
14
15
|
getChannelMembers: async () => [],
|
|
15
|
-
|
|
16
|
+
getRoomInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
|
|
17
|
+
getRooms: async () => [],
|
|
18
|
+
sendMessage: async () => ({ event_id: '$sent' }),
|
|
19
|
+
...over,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fakeTasks(over: Partial<TaskActions> = {}): TaskActions {
|
|
24
|
+
return {
|
|
25
|
+
startTasks: async () => ({ results: [], notify: 'caller', delivery: 'd' }),
|
|
26
|
+
completeTask: async () => ({ status: 'recorded' }),
|
|
27
|
+
describeRole: async () => ({ is_task_assignee: false, can_start_task_threads: true }),
|
|
16
28
|
...over,
|
|
17
29
|
}
|
|
18
30
|
}
|
|
@@ -25,7 +37,7 @@ const defaultProvider = fakeProvider({
|
|
|
25
37
|
has_more: false,
|
|
26
38
|
}),
|
|
27
39
|
getChannelMembers: async () => [{ id: '@alice:hs', name: 'alice', is_agent: false }],
|
|
28
|
-
|
|
40
|
+
getRoomInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
|
|
29
41
|
})
|
|
30
42
|
|
|
31
43
|
const cleanup: Array<() => Promise<void>> = []
|
|
@@ -43,7 +55,7 @@ describe('daemon-socket', () => {
|
|
|
43
55
|
provider: defaultProvider,
|
|
44
56
|
})
|
|
45
57
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
46
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
58
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
47
59
|
cleanup.push(() => server.close())
|
|
48
60
|
|
|
49
61
|
const res = await callDaemon(sockPath, {
|
|
@@ -90,7 +102,7 @@ describe('daemon-socket', () => {
|
|
|
90
102
|
provider,
|
|
91
103
|
})
|
|
92
104
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
93
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
105
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
94
106
|
cleanup.push(() => server.close())
|
|
95
107
|
|
|
96
108
|
const overview = (await callDaemon(sockPath, {
|
|
@@ -111,15 +123,15 @@ describe('daemon-socket', () => {
|
|
|
111
123
|
it('returns an error envelope for unknown spawn-ids', async () => {
|
|
112
124
|
const registry = new SpawnRegistry()
|
|
113
125
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
114
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
126
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
115
127
|
cleanup.push(() => server.close())
|
|
116
128
|
|
|
117
129
|
await expect(
|
|
118
130
|
callDaemon(sockPath, { spawnId: 'unknown', method: 'getRoomHistory', params: {} }),
|
|
119
|
-
).rejects.toThrow(/
|
|
131
|
+
).rejects.toThrow(/binding not owned by caller/)
|
|
120
132
|
})
|
|
121
133
|
|
|
122
|
-
it('routes getChannelMembers and
|
|
134
|
+
it('routes getChannelMembers and getRoomInfo', async () => {
|
|
123
135
|
const registry = new SpawnRegistry()
|
|
124
136
|
const spawnId = registry.register({
|
|
125
137
|
agentName: 'a',
|
|
@@ -127,13 +139,13 @@ describe('daemon-socket', () => {
|
|
|
127
139
|
provider: defaultProvider,
|
|
128
140
|
})
|
|
129
141
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
130
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
142
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
131
143
|
cleanup.push(() => server.close())
|
|
132
144
|
|
|
133
145
|
const members = await callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} })
|
|
134
146
|
expect(members).toEqual([{ id: '@alice:hs', name: 'alice', is_agent: false }])
|
|
135
147
|
|
|
136
|
-
const info = await callDaemon(sockPath, { spawnId, method: '
|
|
148
|
+
const info = await callDaemon(sockPath, { spawnId, method: 'getRoomInfo', params: {} })
|
|
137
149
|
expect(info).toEqual({ id: '!r:hs', name: 'general', transport: 'matrix' })
|
|
138
150
|
})
|
|
139
151
|
|
|
@@ -143,28 +155,28 @@ describe('daemon-socket', () => {
|
|
|
143
155
|
messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
|
|
144
156
|
has_more: false,
|
|
145
157
|
}),
|
|
146
|
-
|
|
158
|
+
getRoomInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
|
|
147
159
|
})
|
|
148
160
|
const providerB = fakeProvider({
|
|
149
161
|
getRoomHistory: async () => ({
|
|
150
162
|
messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
|
|
151
163
|
has_more: false,
|
|
152
164
|
}),
|
|
153
|
-
|
|
165
|
+
getRoomInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
|
|
154
166
|
})
|
|
155
167
|
const registry = new SpawnRegistry()
|
|
156
168
|
const spawnA = registry.register({
|
|
157
|
-
agentName: '
|
|
169
|
+
agentName: 'a',
|
|
158
170
|
threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
|
|
159
171
|
provider: providerA,
|
|
160
172
|
})
|
|
161
173
|
const spawnB = registry.register({
|
|
162
|
-
agentName: '
|
|
174
|
+
agentName: 'a',
|
|
163
175
|
threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
|
|
164
176
|
provider: providerB,
|
|
165
177
|
})
|
|
166
178
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
167
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
179
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
168
180
|
cleanup.push(() => server.close())
|
|
169
181
|
|
|
170
182
|
const [resA, resB] = await Promise.all([
|
|
@@ -175,7 +187,7 @@ describe('daemon-socket', () => {
|
|
|
175
187
|
expect((resA as { messages: Array<{ id: string }> }).messages[0].id).toBe('A1')
|
|
176
188
|
expect((resB as { messages: Array<{ id: string }> }).messages[0].id).toBe('B1')
|
|
177
189
|
|
|
178
|
-
const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: '
|
|
190
|
+
const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getRoomInfo', params: {} })
|
|
179
191
|
expect((infoB as { id: string }).id).toBe('!b:hs')
|
|
180
192
|
})
|
|
181
193
|
|
|
@@ -199,12 +211,12 @@ describe('daemon-socket', () => {
|
|
|
199
211
|
provider: providerA,
|
|
200
212
|
})
|
|
201
213
|
const spawnB = registry.register({
|
|
202
|
-
agentName: '
|
|
214
|
+
agentName: 'a',
|
|
203
215
|
threadRef: { channelId: 'b', threadId: 'b' },
|
|
204
216
|
provider: providerB,
|
|
205
217
|
})
|
|
206
218
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
207
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
219
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
208
220
|
cleanup.push(() => server.close())
|
|
209
221
|
|
|
210
222
|
for (let i = 0; i < 20; i++) {
|
|
@@ -218,4 +230,152 @@ describe('daemon-socket', () => {
|
|
|
218
230
|
expect(res.messages[0].id).toBe(expected)
|
|
219
231
|
}
|
|
220
232
|
})
|
|
233
|
+
|
|
234
|
+
it('routes describeRole to task actions with the bound caller', async () => {
|
|
235
|
+
const seen: unknown[] = []
|
|
236
|
+
const registry = new SpawnRegistry()
|
|
237
|
+
registry.setTaskActions(
|
|
238
|
+
fakeTasks({
|
|
239
|
+
describeRole: async (caller) => {
|
|
240
|
+
seen.push(caller)
|
|
241
|
+
return { is_task_assignee: false, can_start_task_threads: true }
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
const spawnId = registry.register({
|
|
246
|
+
agentName: 'agent-a',
|
|
247
|
+
threadRef: { channelId: 'c', threadId: '$root' },
|
|
248
|
+
provider: defaultProvider,
|
|
249
|
+
})
|
|
250
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
251
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'agent-a' })
|
|
252
|
+
cleanup.push(() => server.close())
|
|
253
|
+
|
|
254
|
+
const res = await callDaemon(sockPath, { spawnId, method: 'describeRole', params: {} })
|
|
255
|
+
expect(res).toEqual({ is_task_assignee: false, can_start_task_threads: true })
|
|
256
|
+
expect(seen[0]).toMatchObject({ agentName: 'agent-a', threadRoot: '$root' })
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('routes sendMessage and getRooms to the provider', async () => {
|
|
260
|
+
const providerCalls: unknown[] = []
|
|
261
|
+
const provider = fakeProvider({
|
|
262
|
+
sendMessage: async (input) => {
|
|
263
|
+
providerCalls.push({ method: 'sendMessage', input })
|
|
264
|
+
return { event_id: '$e' }
|
|
265
|
+
},
|
|
266
|
+
getRooms: async () => {
|
|
267
|
+
providerCalls.push({ method: 'getRooms' })
|
|
268
|
+
return [{ id: '!a:localhost', name: 'general', transport: 'matrix' as const }]
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
const registry = new SpawnRegistry()
|
|
272
|
+
const spawnId = registry.register({
|
|
273
|
+
agentName: 'a',
|
|
274
|
+
threadRef: { channelId: 'c', threadId: 't' },
|
|
275
|
+
provider,
|
|
276
|
+
})
|
|
277
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
278
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
279
|
+
cleanup.push(() => server.close())
|
|
280
|
+
|
|
281
|
+
await callDaemon(sockPath, {
|
|
282
|
+
spawnId,
|
|
283
|
+
method: 'sendMessage',
|
|
284
|
+
params: { room: '!a:localhost', text: 'noted' },
|
|
285
|
+
})
|
|
286
|
+
await callDaemon(sockPath, { spawnId, method: 'getRooms', params: {} })
|
|
287
|
+
expect(providerCalls).toEqual([
|
|
288
|
+
{ method: 'sendMessage', input: { room: '!a:localhost', text: 'noted' } },
|
|
289
|
+
{ method: 'getRooms' },
|
|
290
|
+
])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('refuses sendMessage into a room the caller is not bound to', async () => {
|
|
294
|
+
const provider = fakeProvider({
|
|
295
|
+
sendMessage: async () => {
|
|
296
|
+
throw new Error('not_in_room: this agent is not a member of !elsewhere:localhost')
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
const registry = new SpawnRegistry()
|
|
300
|
+
const spawnId = registry.register({
|
|
301
|
+
agentName: 'a',
|
|
302
|
+
threadRef: { channelId: 'c', threadId: 't' },
|
|
303
|
+
provider,
|
|
304
|
+
})
|
|
305
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
306
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
307
|
+
cleanup.push(() => server.close())
|
|
308
|
+
|
|
309
|
+
await expect(
|
|
310
|
+
callDaemon(sockPath, {
|
|
311
|
+
spawnId,
|
|
312
|
+
method: 'sendMessage',
|
|
313
|
+
params: { room: '!elsewhere:localhost', text: 'hi' },
|
|
314
|
+
}),
|
|
315
|
+
).rejects.toThrow(/not_in_room/)
|
|
316
|
+
})
|
|
221
317
|
})
|
|
318
|
+
|
|
319
|
+
describe('close()', () => {
|
|
320
|
+
it('does not hang on a connected client that never disconnects', async () => {
|
|
321
|
+
// The context-mcp servers spawned beside each agent outlive
|
|
322
|
+
// AcpClient.stop() (it SIGTERMs the agent and doesn't wait; these are its
|
|
323
|
+
// grandchildren). net.Server.close() waits for every open connection and,
|
|
324
|
+
// unlike http.Server, never drops idle ones — so before this was fixed,
|
|
325
|
+
// `zooid dev` hung on shutdown for as long as those processes lived.
|
|
326
|
+
const sockPath = join(tmpdir(), `zooid-close-${randomUUID()}.sock`)
|
|
327
|
+
const registry = new SpawnRegistry()
|
|
328
|
+
const handle = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
329
|
+
|
|
330
|
+
const client = createConnection(sockPath)
|
|
331
|
+
await new Promise<void>((resolve, reject) => {
|
|
332
|
+
client.once('connect', () => resolve())
|
|
333
|
+
client.once('error', reject)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const closed = await Promise.race([
|
|
337
|
+
handle.close().then(() => 'closed' as const),
|
|
338
|
+
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2000)),
|
|
339
|
+
])
|
|
340
|
+
client.destroy()
|
|
341
|
+
expect(closed).toBe('closed')
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('daemon-socket caller identity', () => {
|
|
346
|
+
it('refuses bindings belonging to another agent through either addressing key', async () => {
|
|
347
|
+
const registry = new SpawnRegistry()
|
|
348
|
+
const spawnId = registry.register({
|
|
349
|
+
agentName: 'alice', threadRef: { channelId: 'c', threadId: 't' }, provider: defaultProvider, sessionKey: 't',
|
|
350
|
+
})
|
|
351
|
+
registry.linkSession('alice', 't', 'acp-session-1')
|
|
352
|
+
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
353
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'bob' })
|
|
354
|
+
cleanup.push(() => server.close())
|
|
355
|
+
|
|
356
|
+
await expect(callDaemon(sockPath, { spawnId, method: 'getRoomHistory', params: {} })).rejects.toThrow(NOT_OWNED)
|
|
357
|
+
await expect(callDaemon(sockPath, { acpSessionId: 'acp-session-1', method: 'getChannelMembers', params: {} })).rejects.toThrow(NOT_OWNED)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('serves its owner and makes unknown and unowned ids indistinguishable', async () => {
|
|
361
|
+
const registry = new SpawnRegistry()
|
|
362
|
+
const spawnId = registry.register({
|
|
363
|
+
agentName: 'alice', threadRef: { channelId: 'c', threadId: 't' }, provider: defaultProvider,
|
|
364
|
+
})
|
|
365
|
+
const alicePath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
366
|
+
const bobPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
367
|
+
const alice = await startDaemonSocketServer({ sockPath: alicePath, registry, agentName: 'alice' })
|
|
368
|
+
const bob = await startDaemonSocketServer({ sockPath: bobPath, registry, agentName: 'bob' })
|
|
369
|
+
cleanup.push(() => alice.close())
|
|
370
|
+
cleanup.push(() => bob.close())
|
|
371
|
+
|
|
372
|
+
await expect(callDaemon(alicePath, { spawnId, method: 'getChannelMembers', params: {} })).resolves.toEqual([
|
|
373
|
+
{ id: '@alice:hs', name: 'alice', is_agent: false },
|
|
374
|
+
])
|
|
375
|
+
const unowned = await callDaemon(bobPath, { spawnId, method: 'getRoomInfo', params: {} }).catch((e: Error) => e.message)
|
|
376
|
+
const unknown = await callDaemon(bobPath, { acpSessionId: 'no-such-session', method: 'getRoomInfo', params: {} }).catch((e: Error) => e.message)
|
|
377
|
+
expect(unowned).toBe(unknown)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const NOT_OWNED = 'binding not owned by caller'
|