@sleep2agi/commhub-server 0.9.0-preview.45 → 0.9.0-preview.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.45",
3
+ "version": "0.9.0-preview.47",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,122 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #1548 —— 一个活着、能收发任务的节点可以在名册里永远显示 `blocked`:
7
+ // 只有 report_completion 会把 sessions.status 拉回 idle,而现在大多数 agent 用
8
+ // send_reply(终态)结束任务、用 send_task 派活 —— 这两条路径此前都不碰 sessions.status。
9
+ // 「它刚回了我一条终态消息 / 刚派了一条任务」本身就是最强的活性证据。
10
+ // 只出 `blocked`;`working` 可能真在忙别的任务,不碰(保守)。
11
+ const NET_ID = "net_1548_blocked";
12
+ const USER_ID = "u_1548";
13
+ const NODE_ID = "node_1548_agent";
14
+ const AGENT_ALIAS = "agent-1548";
15
+ const DISPATCHER_NODE_ID = "node_1548_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher-1548";
17
+ const AGENT_TOK_ID = "tok_1548";
18
+
19
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
20
+
21
+ function cleanup() {
22
+ for (const [sql, params] of [
23
+ ["DELETE FROM tasks WHERE network_id = ?1", [NET_ID]],
24
+ ["DELETE FROM inbox WHERE network_id = ?1", [NET_ID]],
25
+ ["DELETE FROM task_events WHERE network_id = ?1", [NET_ID]],
26
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
27
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
28
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
29
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
30
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
31
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
32
+ ] as const) {
33
+ try { db.run(sql, params as any); } catch {}
34
+ }
35
+ }
36
+ beforeEach(cleanup);
37
+ afterAll(cleanup);
38
+
39
+ function seed(agentStatus: string) {
40
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
41
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
42
+ db.run(
43
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
44
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
45
+ [USER_ID, NET_ID],
46
+ );
47
+ for (const [nodeId, alias] of [[DISPATCHER_NODE_ID, DISPATCHER_ALIAS], [NODE_ID, AGENT_ALIAS]] as const) {
48
+ db.run(
49
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
50
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
51
+ [nodeId, alias, alias, NET_ID, `host-${alias}`],
52
+ );
53
+ }
54
+ db.run(
55
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
56
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
57
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
58
+ );
59
+ db.run(
60
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
61
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
62
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, agentStatus, NODE_ID, NET_ID],
63
+ );
64
+ db.run(
65
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
66
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
67
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
68
+ );
69
+ }
70
+ function makeTask(): string {
71
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
72
+ db.run(
73
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
74
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
75
+ [id, DISPATCHER_ALIAS, AGENT_ALIAS, NODE_ID, NET_ID],
76
+ );
77
+ return id;
78
+ }
79
+ function buildHandlers(): Record<string, ToolHandler> {
80
+ const server = new McpServer({ name: "test", version: "0" }) as any;
81
+ const tools: Record<string, ToolHandler> = {};
82
+ const origTool = server.tool.bind(server);
83
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
84
+ const origRegisterTool = server.registerTool?.bind(server);
85
+ if (origRegisterTool) {
86
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
87
+ }
88
+ registerTools(server, undefined, NET_ID, USER_ID, AGENT_ALIAS, true, AGENT_TOK_ID);
89
+ return tools;
90
+ }
91
+ async function call(handler: ToolHandler, args: any): Promise<any> {
92
+ const r = await handler(args);
93
+ return JSON.parse(r.content[0]!.text);
94
+ }
95
+ function agentStatus(): string {
96
+ return (db.get("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", [AGENT_ALIAS, NET_ID]) as { status: string }).status;
97
+ }
98
+
99
+ describe("#1548 blocked has an exit: a terminal reply or an outbound task proves the agent is alive", () => {
100
+ test("terminal send_reply from a blocked agent puts it back to idle", async () => {
101
+ seed("blocked");
102
+ const tools = buildHandlers();
103
+ const taskId = makeTask();
104
+ const r = await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
105
+ expect(r.ok, JSON.stringify(r)).toBe(true);
106
+ expect(agentStatus()).toBe("idle");
107
+ });
108
+ test("send_task from a blocked agent puts it back to idle", async () => {
109
+ seed("blocked");
110
+ const tools = buildHandlers();
111
+ const r = await call(tools.send_task!, { alias: DISPATCHER_ALIAS, task: "ping", priority: "normal", from_session: AGENT_ALIAS, network_id: NET_ID });
112
+ expect(r.ok, JSON.stringify(r)).toBe(true);
113
+ expect(agentStatus()).toBe("idle");
114
+ });
115
+ test("a working agent is left alone (it may be busy with another task)", async () => {
116
+ seed("working");
117
+ const tools = buildHandlers();
118
+ const taskId = makeTask();
119
+ await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
120
+ expect(agentStatus()).toBe("working");
121
+ });
122
+ });
@@ -0,0 +1,66 @@
1
+ // #1756 — 标准 MCP 客户端的 tools/list 必须能成功。
2
+ //
3
+ // 2026-09-02 量到:main 上 client.listTools() 直接抛
4
+ // McpError -32603: undefined is not an object (evaluating 'schema._zod')
5
+ // 逐字段二分,肇事者是四处 `z.record(z.unknown())`(report_status.config_snapshot /
6
+ // send_desktop_message.meta / update_node_config.patch.flags / create_node.node_spec.*):
7
+ // zod v4 的 z.record 要两个参数(key, value),单参数时 value schema 是 undefined,
8
+ // SDK 生成 JSON Schema 时对它取 `_zod` 就炸。修法:z.record(z.string(), z.unknown())。
9
+ //
10
+ // 这条测试守两件事:① listTools 整体成功且条数 == 登记表条数;② 每个工具单独都能
11
+ // toJSONSchema —— 第 ② 条让下一次谁再写单参 record 时,红的是「哪个工具哪个字段」。
12
+ // 跑法:cd server && COMMHUB_DB=/tmp/lt.db bun test src/tools-list-json-schema.test.ts
13
+ import { describe, expect, test } from "bun:test";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { z } from "zod/v4";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ async function connect() {
21
+ const server = new McpServer({ name: "list-tools-test", version: "1" });
22
+ registerTools(server, undefined, "net_list_tools", "u_list_tools", "u_list_tools", false, "tok_list_tools");
23
+ const client = new Client({ name: "list-tools-client", version: "1" });
24
+ const [ct, st] = InMemoryTransport.createLinkedPair();
25
+ await server.connect(st);
26
+ await client.connect(ct);
27
+ return { server, client, close: async () => { await client.close(); await server.close(); } };
28
+ }
29
+
30
+ describe("#1756 tools/list", () => {
31
+ test("every registered tool converts to JSON Schema on its own (names the offender)", async () => {
32
+ const { server, close } = await connect();
33
+ try {
34
+ const reg: Record<string, any> = (server as any)._registeredTools;
35
+ const names = Object.keys(reg);
36
+ expect(names.length).toBeGreaterThan(40);
37
+ const bad: string[] = [];
38
+ for (const n of names) {
39
+ const schema = reg[n]?.inputSchema;
40
+ const shape = schema?.shape ?? schema ?? {};
41
+ for (const [k, v] of Object.entries(shape)) {
42
+ try { z.toJSONSchema(z.object({ [k]: v as any })); } catch (e: any) { bad.push(`${n}.${k}: ${String(e?.message).slice(0, 60)}`); }
43
+ }
44
+ }
45
+ expect(bad).toEqual([]);
46
+ } finally { await close(); }
47
+ }, 20_000);
48
+
49
+ test("client.listTools() succeeds and returns every registered tool", async () => {
50
+ const { server, client, close } = await connect();
51
+ try {
52
+ const reg: Record<string, any> = (server as any)._registeredTools;
53
+ const listed = (await client.listTools()).tools;
54
+ expect(listed.length).toBe(Object.keys(reg).length);
55
+ const byName = new Set(listed.map(t => t.name));
56
+ for (const n of ["report_status", "send_desktop_message", "update_node_config", "create_node", "read_node_rules_file"]) {
57
+ expect(byName.has(n), n).toBe(true);
58
+ }
59
+ // 修过的四个字段在 JSON Schema 里必须是 object(不是 undefined / 空)
60
+ const schemaOf = (n: string) => listed.find(t => t.name === n)!.inputSchema as any;
61
+ expect(schemaOf("report_status").properties.config_snapshot).toBeDefined();
62
+ expect(schemaOf("send_desktop_message").properties.meta.type).toBe("object");
63
+ expect(schemaOf("update_node_config").properties.patch.properties.flags.type).toBe("object");
64
+ } finally { await close(); }
65
+ }, 20_000);
66
+ });
package/src/tools.ts CHANGED
@@ -362,6 +362,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
362
362
  return sql;
363
363
  };
364
364
 
365
+ /** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
366
+ const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
367
+ if (!alias) return;
368
+ const params: any[] = [alias];
369
+ let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
370
+ sql = addScope(sql, params, networkId ?? null);
371
+ try {
372
+ db.run(sql, params);
373
+ } catch {
374
+ // 展示层的一次修正失败不能影响主路径。
375
+ }
376
+ };
377
+
365
378
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
366
379
 
367
380
  // Delegates to the shared membership query (network-scope.ts) — was a
@@ -598,7 +611,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
598
611
  // can grey out remote-restart for them.
599
612
  config_snapshot: z.object({
600
613
  model: z.string().max(200).optional().nullable(),
601
- flags: z.record(z.unknown()).optional(),
614
+ flags: z.record(z.string(), z.unknown()).optional(),
602
615
  config_revision: z.number().int().min(0).optional(),
603
616
  config_update_capable: z.boolean().optional(),
604
617
  peer_reply_inbox_capable: z.literal(true).optional(),
@@ -1598,6 +1611,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1598
1611
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1599
1612
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
1600
1613
  db.run(touchSql, touchParams);
1614
+ // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
1615
+ releaseBlockedSession(from_session, effectiveNetId);
1601
1616
  });
1602
1617
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
1603
1618
  // Only stamp the dedup index after the inbox/tasks transaction
@@ -1938,6 +1953,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1938
1953
  throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1939
1954
  }
1940
1955
  syncScheduledRunForTask(in_reply_to, effectiveNetId);
1956
+ // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
1957
+ // 只出 blocked;working 可能真在忙别的任务,不碰。
1958
+ releaseBlockedSession(from_session, effectiveNetId);
1941
1959
  return { ok: true as const, replyLogged: true };
1942
1960
  }
1943
1961
  return { ok: true as const, replyLogged: false };
@@ -2377,7 +2395,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2377
2395
  message: z.string().min(1).max(10000),
2378
2396
  severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2379
2397
  kind: z.string().min(1).max(80).optional().default("agent_message"),
2380
- meta: z.record(z.unknown()).optional(),
2398
+ meta: z.record(z.string(), z.unknown()).optional(),
2381
2399
  from_session: z.string().max(200).optional(),
2382
2400
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2383
2401
  },
@@ -2674,7 +2692,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2674
2692
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
2675
2693
  patch: z.object({
2676
2694
  model: z.string().max(200).optional(),
2677
- flags: z.record(z.unknown()).optional(),
2695
+ flags: z.record(z.string(), z.unknown()).optional(),
2678
2696
  // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2679
2697
  // reads config.channels once at boot to fork per-channel workers,
2680
2698
  // so the swap takes effect via process restart). Not
@@ -2682,7 +2700,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2682
2700
  // (network scope) only.
2683
2701
  //
2684
2702
  // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2685
- // `z.record(z.unknown())`): trust nothing at the wire boundary,
2703
+ // `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
2686
2704
  // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2687
2705
  // JSON (typeof + allowlist + dedup + case-fold). A strict
2688
2706
  // `z.array(z.string())` would fail-fast on a single non-string
@@ -3477,7 +3495,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3477
3495
  name: z.string().min(1).max(64),
3478
3496
  runtime: z.string().min(1).max(64),
3479
3497
  model: z.string().min(1).max(100).optional().nullable(),
3480
- flags: z.record(z.unknown()).optional(),
3498
+ flags: z.record(z.string(), z.unknown()).optional(),
3481
3499
  env_refs: z.array(z.string().max(64)).optional(),
3482
3500
  channels: z.array(z.unknown()).optional(),
3483
3501
  }),