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

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.46",
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,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
@@ -598,7 +598,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
598
598
  // can grey out remote-restart for them.
599
599
  config_snapshot: z.object({
600
600
  model: z.string().max(200).optional().nullable(),
601
- flags: z.record(z.unknown()).optional(),
601
+ flags: z.record(z.string(), z.unknown()).optional(),
602
602
  config_revision: z.number().int().min(0).optional(),
603
603
  config_update_capable: z.boolean().optional(),
604
604
  peer_reply_inbox_capable: z.literal(true).optional(),
@@ -2377,7 +2377,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2377
2377
  message: z.string().min(1).max(10000),
2378
2378
  severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2379
2379
  kind: z.string().min(1).max(80).optional().default("agent_message"),
2380
- meta: z.record(z.unknown()).optional(),
2380
+ meta: z.record(z.string(), z.unknown()).optional(),
2381
2381
  from_session: z.string().max(200).optional(),
2382
2382
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2383
2383
  },
@@ -2674,7 +2674,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2674
2674
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
2675
2675
  patch: z.object({
2676
2676
  model: z.string().max(200).optional(),
2677
- flags: z.record(z.unknown()).optional(),
2677
+ flags: z.record(z.string(), z.unknown()).optional(),
2678
2678
  // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2679
2679
  // reads config.channels once at boot to fork per-channel workers,
2680
2680
  // so the swap takes effect via process restart). Not
@@ -2682,7 +2682,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2682
2682
  // (network scope) only.
2683
2683
  //
2684
2684
  // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2685
- // `z.record(z.unknown())`): trust nothing at the wire boundary,
2685
+ // `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
2686
2686
  // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2687
2687
  // JSON (typeof + allowlist + dedup + case-fold). A strict
2688
2688
  // `z.array(z.string())` would fail-fast on a single non-string
@@ -3477,7 +3477,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3477
3477
  name: z.string().min(1).max(64),
3478
3478
  runtime: z.string().min(1).max(64),
3479
3479
  model: z.string().min(1).max(100).optional().nullable(),
3480
- flags: z.record(z.unknown()).optional(),
3480
+ flags: z.record(z.string(), z.unknown()).optional(),
3481
3481
  env_refs: z.array(z.string().max(64)).optional(),
3482
3482
  channels: z.array(z.unknown()).optional(),
3483
3483
  }),