@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 +1 -1
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +23 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
}),
|