@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.
- package/dist/bin.js +44 -4
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-L5USYHTS.js → chunk-IZ2JCKE4.js} +168 -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 +152 -19
- package/src/daemon-socket.ts +124 -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-L5USYHTS.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`)
|
|
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
5
5
|
import { createConnection } from 'node:net'
|
|
6
6
|
import { SpawnRegistry } from './spawn-registry.js'
|
|
7
7
|
import { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
|
|
8
|
-
import type { TransportContextProvider } from '@zooid/core'
|
|
8
|
+
import type { TaskActions, TransportContextProvider } from '@zooid/core'
|
|
9
9
|
|
|
10
10
|
function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
|
|
11
11
|
return {
|
|
@@ -13,7 +13,18 @@ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
|
|
|
13
13
|
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
14
14
|
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
15
15
|
getChannelMembers: async () => [],
|
|
16
|
-
|
|
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 }),
|
|
17
28
|
...over,
|
|
18
29
|
}
|
|
19
30
|
}
|
|
@@ -26,7 +37,7 @@ const defaultProvider = fakeProvider({
|
|
|
26
37
|
has_more: false,
|
|
27
38
|
}),
|
|
28
39
|
getChannelMembers: async () => [{ id: '@alice:hs', name: 'alice', is_agent: false }],
|
|
29
|
-
|
|
40
|
+
getRoomInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
|
|
30
41
|
})
|
|
31
42
|
|
|
32
43
|
const cleanup: Array<() => Promise<void>> = []
|
|
@@ -44,7 +55,7 @@ describe('daemon-socket', () => {
|
|
|
44
55
|
provider: defaultProvider,
|
|
45
56
|
})
|
|
46
57
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
47
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
58
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
48
59
|
cleanup.push(() => server.close())
|
|
49
60
|
|
|
50
61
|
const res = await callDaemon(sockPath, {
|
|
@@ -91,7 +102,7 @@ describe('daemon-socket', () => {
|
|
|
91
102
|
provider,
|
|
92
103
|
})
|
|
93
104
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
94
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
105
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
95
106
|
cleanup.push(() => server.close())
|
|
96
107
|
|
|
97
108
|
const overview = (await callDaemon(sockPath, {
|
|
@@ -112,15 +123,15 @@ describe('daemon-socket', () => {
|
|
|
112
123
|
it('returns an error envelope for unknown spawn-ids', async () => {
|
|
113
124
|
const registry = new SpawnRegistry()
|
|
114
125
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
115
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
126
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
116
127
|
cleanup.push(() => server.close())
|
|
117
128
|
|
|
118
129
|
await expect(
|
|
119
130
|
callDaemon(sockPath, { spawnId: 'unknown', method: 'getRoomHistory', params: {} }),
|
|
120
|
-
).rejects.toThrow(/
|
|
131
|
+
).rejects.toThrow(/binding not owned by caller/)
|
|
121
132
|
})
|
|
122
133
|
|
|
123
|
-
it('routes getChannelMembers and
|
|
134
|
+
it('routes getChannelMembers and getRoomInfo', async () => {
|
|
124
135
|
const registry = new SpawnRegistry()
|
|
125
136
|
const spawnId = registry.register({
|
|
126
137
|
agentName: 'a',
|
|
@@ -128,13 +139,13 @@ describe('daemon-socket', () => {
|
|
|
128
139
|
provider: defaultProvider,
|
|
129
140
|
})
|
|
130
141
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
131
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
142
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
132
143
|
cleanup.push(() => server.close())
|
|
133
144
|
|
|
134
145
|
const members = await callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} })
|
|
135
146
|
expect(members).toEqual([{ id: '@alice:hs', name: 'alice', is_agent: false }])
|
|
136
147
|
|
|
137
|
-
const info = await callDaemon(sockPath, { spawnId, method: '
|
|
148
|
+
const info = await callDaemon(sockPath, { spawnId, method: 'getRoomInfo', params: {} })
|
|
138
149
|
expect(info).toEqual({ id: '!r:hs', name: 'general', transport: 'matrix' })
|
|
139
150
|
})
|
|
140
151
|
|
|
@@ -144,28 +155,28 @@ describe('daemon-socket', () => {
|
|
|
144
155
|
messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
|
|
145
156
|
has_more: false,
|
|
146
157
|
}),
|
|
147
|
-
|
|
158
|
+
getRoomInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
|
|
148
159
|
})
|
|
149
160
|
const providerB = fakeProvider({
|
|
150
161
|
getRoomHistory: async () => ({
|
|
151
162
|
messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
|
|
152
163
|
has_more: false,
|
|
153
164
|
}),
|
|
154
|
-
|
|
165
|
+
getRoomInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
|
|
155
166
|
})
|
|
156
167
|
const registry = new SpawnRegistry()
|
|
157
168
|
const spawnA = registry.register({
|
|
158
|
-
agentName: '
|
|
169
|
+
agentName: 'a',
|
|
159
170
|
threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
|
|
160
171
|
provider: providerA,
|
|
161
172
|
})
|
|
162
173
|
const spawnB = registry.register({
|
|
163
|
-
agentName: '
|
|
174
|
+
agentName: 'a',
|
|
164
175
|
threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
|
|
165
176
|
provider: providerB,
|
|
166
177
|
})
|
|
167
178
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
168
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
179
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
169
180
|
cleanup.push(() => server.close())
|
|
170
181
|
|
|
171
182
|
const [resA, resB] = await Promise.all([
|
|
@@ -176,7 +187,7 @@ describe('daemon-socket', () => {
|
|
|
176
187
|
expect((resA as { messages: Array<{ id: string }> }).messages[0].id).toBe('A1')
|
|
177
188
|
expect((resB as { messages: Array<{ id: string }> }).messages[0].id).toBe('B1')
|
|
178
189
|
|
|
179
|
-
const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: '
|
|
190
|
+
const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getRoomInfo', params: {} })
|
|
180
191
|
expect((infoB as { id: string }).id).toBe('!b:hs')
|
|
181
192
|
})
|
|
182
193
|
|
|
@@ -200,12 +211,12 @@ describe('daemon-socket', () => {
|
|
|
200
211
|
provider: providerA,
|
|
201
212
|
})
|
|
202
213
|
const spawnB = registry.register({
|
|
203
|
-
agentName: '
|
|
214
|
+
agentName: 'a',
|
|
204
215
|
threadRef: { channelId: 'b', threadId: 'b' },
|
|
205
216
|
provider: providerB,
|
|
206
217
|
})
|
|
207
218
|
const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
|
|
208
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
219
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
209
220
|
cleanup.push(() => server.close())
|
|
210
221
|
|
|
211
222
|
for (let i = 0; i < 20; i++) {
|
|
@@ -219,6 +230,90 @@ describe('daemon-socket', () => {
|
|
|
219
230
|
expect(res.messages[0].id).toBe(expected)
|
|
220
231
|
}
|
|
221
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
|
+
})
|
|
222
317
|
})
|
|
223
318
|
|
|
224
319
|
describe('close()', () => {
|
|
@@ -230,7 +325,7 @@ describe('close()', () => {
|
|
|
230
325
|
// `zooid dev` hung on shutdown for as long as those processes lived.
|
|
231
326
|
const sockPath = join(tmpdir(), `zooid-close-${randomUUID()}.sock`)
|
|
232
327
|
const registry = new SpawnRegistry()
|
|
233
|
-
const handle = await startDaemonSocketServer({ sockPath, registry })
|
|
328
|
+
const handle = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
|
|
234
329
|
|
|
235
330
|
const client = createConnection(sockPath)
|
|
236
331
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -246,3 +341,41 @@ describe('close()', () => {
|
|
|
246
341
|
expect(closed).toBe('closed')
|
|
247
342
|
})
|
|
248
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'
|