@sleep2agi/commhub-server 0.9.0-preview.145 → 0.9.0-preview.146
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/departments-mcp.test.ts +128 -0
- package/src/departments.ts +5 -2
- package/src/mcp-context-cost-http.test.ts +5 -1
- package/src/node-permissions.ts +2 -0
- package/src/tool-audience-http.test.ts +5 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools-list-json-schema.test.ts +27 -0
- package/src/tools.ts +91 -0
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.146",
|
|
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,128 @@
|
|
|
1
|
+
// #1894 —— 组织架构的 MCP 面:Agent 用 API 就能建部门、把 Agent 归进去,
|
|
2
|
+
// 不用人手动点。这里跑的是真实的 in-process MCP transport(不是直接调函数),
|
|
3
|
+
// 因为要验的正是「工具真的注册上、参数真的能过 schema、权限真的按 caller 判」。
|
|
4
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { db } from "./db.js";
|
|
9
|
+
import { registerTools } from "./tools.js";
|
|
10
|
+
|
|
11
|
+
const NET = "net_dept_mcp";
|
|
12
|
+
const OWNER = "u_dept_mcp_owner";
|
|
13
|
+
const OTHER = "u_dept_mcp_other";
|
|
14
|
+
const AGENT_A = "n_dept_mcp_a";
|
|
15
|
+
const AGENT_B = "n_dept_mcp_b";
|
|
16
|
+
|
|
17
|
+
function cleanup() {
|
|
18
|
+
for (const [table, col] of [
|
|
19
|
+
["audit_log", "network_id"], ["nodes", "network_id"], ["network_departments", "network_id"],
|
|
20
|
+
["network_members", "network_id"], ["networks", "network_id"],
|
|
21
|
+
] as const) {
|
|
22
|
+
try { db.run(`DELETE FROM ${table} WHERE ${col} = ?1`, [NET]); } catch {}
|
|
23
|
+
}
|
|
24
|
+
try { db.run("DELETE FROM users WHERE user_id IN (?1, ?2)", [OWNER, OTHER]); } catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function seed() {
|
|
28
|
+
cleanup();
|
|
29
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [OWNER, OWNER]);
|
|
30
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [OTHER, OTHER]);
|
|
31
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))", [NET, NET, OWNER]);
|
|
32
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [OWNER, NET]);
|
|
33
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'member', datetime('now'))", [OTHER, NET]);
|
|
34
|
+
for (const [id, alias] of [[AGENT_A, "dept-agent-a"], [AGENT_B, "dept-agent-b"]] as const) {
|
|
35
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))", [id, alias, NET]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function connect(callerUserId: string, alias = "dept-agent-a") {
|
|
40
|
+
const server = new McpServer({ name: "dept-mcp-test", version: "1" });
|
|
41
|
+
registerTools(server, undefined, NET, callerUserId, alias, true, null);
|
|
42
|
+
const client = new Client({ name: "dept-mcp-client", version: "1" });
|
|
43
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
44
|
+
await server.connect(st);
|
|
45
|
+
await client.connect(ct);
|
|
46
|
+
return { client, server };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Result = { content: Array<{ type: string; text?: string }>; isError?: boolean };
|
|
50
|
+
const json = (r: Result) => JSON.parse(String(r.content[0]?.text ?? "{}"));
|
|
51
|
+
|
|
52
|
+
beforeEach(seed);
|
|
53
|
+
afterAll(cleanup);
|
|
54
|
+
|
|
55
|
+
describe("#1894 departments over MCP", () => {
|
|
56
|
+
test("owner can list, create, assign and read back the counts", async () => {
|
|
57
|
+
const { client, server } = await connect(OWNER);
|
|
58
|
+
try {
|
|
59
|
+
const before = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
|
|
60
|
+
expect(before.ok).toBe(true);
|
|
61
|
+
expect(before.unassigned_agents).toBe(2);
|
|
62
|
+
|
|
63
|
+
const created = json(await client.callTool({
|
|
64
|
+
name: "departments", arguments: { action: "create", name: "TMAI", department_id: "dept_test_tmai" },
|
|
65
|
+
}) as Result);
|
|
66
|
+
expect(created.ok).toBe(true);
|
|
67
|
+
expect(created.department.id).toBe("dept_test_tmai");
|
|
68
|
+
|
|
69
|
+
const child = json(await client.callTool({
|
|
70
|
+
name: "departments", arguments: { action: "create", name: "TMAI-内容", parent_id: "dept_test_tmai" },
|
|
71
|
+
}) as Result);
|
|
72
|
+
expect(child.ok).toBe(true);
|
|
73
|
+
expect(child.department.parent_id).toBe("dept_test_tmai");
|
|
74
|
+
|
|
75
|
+
const assigned = json(await client.callTool({
|
|
76
|
+
name: "departments", arguments: { action: "assign_agent", alias: "dept-agent-a", department_id: "dept_test_tmai" },
|
|
77
|
+
}) as Result);
|
|
78
|
+
expect(assigned.ok).toBe(true);
|
|
79
|
+
expect(assigned.node_id).toBe(AGENT_A);
|
|
80
|
+
|
|
81
|
+
const after = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
|
|
82
|
+
expect(after.unassigned_agents).toBe(1);
|
|
83
|
+
const tmai = after.departments.find((d: any) => d.id === "dept_test_tmai");
|
|
84
|
+
expect(tmai.agent_count).toBe(1);
|
|
85
|
+
const agent = after.agents.find((a: any) => a.node_id === AGENT_A);
|
|
86
|
+
expect(agent.department_id).toBe("dept_test_tmai");
|
|
87
|
+
|
|
88
|
+
// 清空归属同样要能通过 API 做
|
|
89
|
+
const cleared = json(await client.callTool({
|
|
90
|
+
name: "departments", arguments: { action: "assign_agent", node_id: AGENT_A, department_id: null },
|
|
91
|
+
}) as Result);
|
|
92
|
+
expect(cleared.ok).toBe(true);
|
|
93
|
+
expect(cleared.department_id).toBeNull();
|
|
94
|
+
} finally {
|
|
95
|
+
await server.close();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a plain member cannot write departments", async () => {
|
|
100
|
+
const { client, server } = await connect(OTHER, "dept-agent-b");
|
|
101
|
+
try {
|
|
102
|
+
const read = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
|
|
103
|
+
expect(read.ok).toBe(true);
|
|
104
|
+
const created = json(await client.callTool({
|
|
105
|
+
name: "departments", arguments: { action: "create", name: "不该被建出来" },
|
|
106
|
+
}) as Result);
|
|
107
|
+
expect(created.ok).toBe(false);
|
|
108
|
+
const assigned = json(await client.callTool({
|
|
109
|
+
name: "departments", arguments: { action: "assign_agent", node_id: AGENT_B, department_id: null },
|
|
110
|
+
}) as Result);
|
|
111
|
+
expect(assigned.ok).toBe(false);
|
|
112
|
+
} finally {
|
|
113
|
+
await server.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("deleting a department that still has agents is refused", async () => {
|
|
118
|
+
const { client, server } = await connect(OWNER);
|
|
119
|
+
try {
|
|
120
|
+
json(await client.callTool({ name: "departments", arguments: { action: "create", name: "B 站", department_id: "dept_test_b" } }) as Result);
|
|
121
|
+
json(await client.callTool({ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_B, department_id: "dept_test_b" } }) as Result);
|
|
122
|
+
const refused = json(await client.callTool({ name: "departments", arguments: { action: "delete", department_id: "dept_test_b" } }) as Result);
|
|
123
|
+
expect(refused.ok).toBe(false);
|
|
124
|
+
} finally {
|
|
125
|
+
await server.close();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/departments.ts
CHANGED
|
@@ -218,11 +218,14 @@ export function updateDepartment(networkId: string, id: string, body: Record<str
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/** 只删空部门:有子部门 / 有成员 → 409(带数字,界面照着说)。 */
|
|
221
|
-
export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number }) {
|
|
221
|
+
export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number; agents?: number }) {
|
|
222
222
|
if (!one(networkId, id)) return fail(404, "department_not_found");
|
|
223
223
|
const children = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id)?.n ?? 0;
|
|
224
224
|
const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
|
|
225
|
-
|
|
225
|
+
// #1894 —— Agent 的部门归属是 nodes.department_id(显式归属线)。以前只数人类成员,
|
|
226
|
+
// 于是「部门里还有 300 个 Agent」也能被删掉,留下指向不存在部门的悬空归属。
|
|
227
|
+
const agents = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM nodes WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
|
|
228
|
+
if (children || members || agents) return { ...fail(409, "department_not_empty"), children, members, agents };
|
|
226
229
|
// RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
|
|
227
230
|
groupTx(() => {
|
|
228
231
|
db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
|
|
@@ -29,7 +29,11 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
|
|
|
29
29
|
// Hub #938 clone_node is audience both (60 tools / 58,169 B). OpenCode V2
|
|
30
30
|
// catalog adds read_opencode_catalog and get_opencode_catalog on that user
|
|
31
31
|
// list. Measured together: 62 tools / 59,409 B. Ceiling keeps ~401 B.
|
|
32
|
-
|
|
32
|
+
// #1894(2026-10-23):新增 `departments` 一个工具(组织架构的 list/create/update/
|
|
33
|
+
// delete/assign_agent 合并成一个,避免 5 个工具把上下文顶穿)。实测 63 个工具
|
|
34
|
+
// 60,089 B,比原上限高 279 B —— 这是**有意的功能增长**,不是描述悄悄变长,
|
|
35
|
+
// 所以显式抬到 60_500(留 411 B 余量)并在同一行写清理由。
|
|
36
|
+
const TOOLS_LIST_MAX_BYTES = 60_500;
|
|
33
37
|
/** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
|
|
34
38
|
const TOOL_MAX_BYTES = 9_000;
|
|
35
39
|
/** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
|
package/src/node-permissions.ts
CHANGED
|
@@ -269,6 +269,8 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
269
269
|
broadcast: "broadcast",
|
|
270
270
|
// 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
|
|
271
271
|
requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
|
|
272
|
+
// #1894 —— 组织架构:读是常规读,写由工具内部再判网络 owner/admin(普通成员/节点写会被拒并 audit)。
|
|
273
|
+
departments: "requirements",
|
|
272
274
|
requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
|
|
273
275
|
requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
|
|
274
276
|
// 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
|
|
@@ -207,7 +207,11 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
207
207
|
// tools. Measured on this combined tree: all 95 / 85,069 B; node 62 / 63,281 B
|
|
208
208
|
// unchanged (protocol tools stay off the node list); user 62 / 59,409 B.
|
|
209
209
|
// Ceilings keep about 400 B and nothing else.
|
|
210
|
-
|
|
210
|
+
// #1894(2026-10-23):新增一个 `departments` MCP 工具(list/create/update/delete/
|
|
211
|
+
// assign_agent 合并成一个,尽量小)。实测 node 64,112 B / user 60,117 B,比原上限
|
|
212
|
+
// 分别高 412 / 279 B —— 这是有意的功能增长(组织架构可由 Agent 直接调),不是文案偷偷变长。
|
|
213
|
+
// 留约 400 B 余量;再要加工具时请先合并动作或砍描述,别连续抬上限。
|
|
214
|
+
const CEILINGS = { node: 64_500, user: 60_500, all: 86_500 } as const;
|
|
211
215
|
test("node / user / all", async () => {
|
|
212
216
|
const node = await listTools(T.node_normal);
|
|
213
217
|
const user = await listTools(T.owner);
|
package/src/tool-audience.ts
CHANGED
|
@@ -59,6 +59,8 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
59
59
|
upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
|
|
60
60
|
list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
|
|
61
61
|
requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
|
|
62
|
+
// #1894 —— 组织架构:人和 Agent 都能用(Agent 用它归部门,人用它看树)。
|
|
63
|
+
departments: "both",
|
|
62
64
|
requirements_update: "both", requirements_checklist_toggle: "both", requirements_upsert_by_external_ref: "both",
|
|
63
65
|
requirements_events: "both", requirements_comment: "both", projects_list: "both",
|
|
64
66
|
};
|
|
@@ -65,3 +65,30 @@ describe("#1756 tools/list", () => {
|
|
|
65
65
|
} finally { await close(); }
|
|
66
66
|
}, 20_000);
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
// #1075 / #2662 —— `node_secrets_capable` 是**节点自述**的一格,运行时把它放在
|
|
70
|
+
// `config_snapshot` 里(agent-node/src/cli.ts:1894)。而 zod 对象默认**丢掉未声明
|
|
71
|
+
// 的键**:只要 `config_snapshot` 的形状里没有这一条,节点上报得再好,到 Hub 也被
|
|
72
|
+
// 静默剥掉 → 客户端「保存密钥」永远是灰的,日志里一个字都不会说。
|
|
73
|
+
//
|
|
74
|
+
// 所以钉在这里:`report_status.config_snapshot` 的形状里**必须有**这一格。
|
|
75
|
+
// 剥掉它的方式有很多种(重写对象、加 .strict()、换 z.record),这条断言对它们都红。
|
|
76
|
+
describe("#1075 node_secrets_capable survives report_status", () => {
|
|
77
|
+
test("config_snapshot's shape declares node_secrets_capable", async () => {
|
|
78
|
+
const { server, close } = await connect();
|
|
79
|
+
try {
|
|
80
|
+
const reg: Record<string, any> = (server as any)._registeredTools;
|
|
81
|
+
const shape = reg["report_status"]?.inputSchema?.shape ?? {};
|
|
82
|
+
const snapshotField = shape["config_snapshot"];
|
|
83
|
+
expect(snapshotField).toBeTruthy();
|
|
84
|
+
// config_snapshot 是 ZodOptional 包着 ZodObject:.unwrap() 之后才有 shape。
|
|
85
|
+
const unwrapped = (snapshotField as any)?.unwrap ? (snapshotField as any).unwrap() : snapshotField;
|
|
86
|
+
const inner = (unwrapped as any)?.shape ?? {};
|
|
87
|
+
expect(Object.keys(inner)).toContain("node_secrets_capable");
|
|
88
|
+
// 同族的另一格(daemon 级能力)也在这份形状里,顺手一起钉住。
|
|
89
|
+
expect(Object.keys(inner)).toContain("daemon_capabilities");
|
|
90
|
+
} finally {
|
|
91
|
+
await close();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -107,6 +107,7 @@ import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue
|
|
|
107
107
|
import { parseHubTimestamp } from "./hub-timestamp";
|
|
108
108
|
import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
109
109
|
import { listTaskLateReplies } from "./task-late-replies.js";
|
|
110
|
+
import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setAgentDepartment, updateDepartment } from "./departments.js";
|
|
110
111
|
|
|
111
112
|
function ts(): string {
|
|
112
113
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -425,6 +426,96 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
425
426
|
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
426
427
|
});
|
|
427
428
|
|
|
429
|
+
// ── #1894 组织架构:Agent 用 API 就能建部门、把 Agent 归进去(admin 2026-10-23:
|
|
430
|
+
// 不要靠人手动点)。写权限 = 网络 owner/admin(实测 `canWrite` 对普通成员也是 true,
|
|
431
|
+
// 口径太宽,所以这里显式判角色);读权限 = 能读这个网络的人。
|
|
432
|
+
// 归属写的是 nodes.department_id,显式归属,绝不按负责人推断。
|
|
433
|
+
const deptReply = (value: Record<string, unknown>) => ({
|
|
434
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
435
|
+
});
|
|
436
|
+
const canManageDepartments = (netId: string | null): boolean => {
|
|
437
|
+
if (!netId) return false;
|
|
438
|
+
if (!enforceUserId) return canWrite(netId); // legacy open mode
|
|
439
|
+
const role = getUserNetworkRole(enforceUserId, netId);
|
|
440
|
+
return role === "owner" || role === "admin";
|
|
441
|
+
};
|
|
442
|
+
const deptFail = (result: any) =>
|
|
443
|
+
deptReply({ ok: false, error: result?.error ?? "department_failed", status: result?.status ?? 400, ...(result?.children || result?.members || result?.agents ? { children: result.children, members: result.members, agents: result.agents } : {}) });
|
|
444
|
+
|
|
445
|
+
server.tool(
|
|
446
|
+
"departments",
|
|
447
|
+
"Agent org structure: list / create / update / delete / assign_agent. Writes need network owner or admin; assign_agent sets nodes.department_id.",
|
|
448
|
+
{
|
|
449
|
+
action: z.enum(["list", "create", "update", "delete", "assign_agent"]),
|
|
450
|
+
department_id: z.string().max(64).nullable().optional(),
|
|
451
|
+
name: z.string().min(1).max(80).optional(),
|
|
452
|
+
parent_id: z.string().max(64).nullable().optional(),
|
|
453
|
+
node_id: z.string().max(200).optional(),
|
|
454
|
+
alias: z.string().max(200).optional(),
|
|
455
|
+
network_id: z.string().max(200).optional(),
|
|
456
|
+
},
|
|
457
|
+
async ({ action, department_id, name, parent_id, node_id, alias, network_id: clientNetId }) => {
|
|
458
|
+
const netId = getNetworkId(clientNetId);
|
|
459
|
+
if (!netId) return deptReply({ ok: false, error: "network_required" });
|
|
460
|
+
if (action === "list") {
|
|
461
|
+
if (!canManageDepartments(netId) && !canWriteHuman(netId)) return writeDeniedReply(netId, "read") as any;
|
|
462
|
+
const listed = listDepartments(netId);
|
|
463
|
+
const agents = db.all<{ node_id: string; alias: string; department_id: string | null }>(
|
|
464
|
+
"SELECT node_id, alias, department_id FROM nodes WHERE network_id = ?1 ORDER BY alias", netId,
|
|
465
|
+
);
|
|
466
|
+
const counts = new Map<string, number>();
|
|
467
|
+
let unassigned = 0;
|
|
468
|
+
for (const a of agents) {
|
|
469
|
+
if (a.department_id) counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
|
|
470
|
+
else unassigned += 1;
|
|
471
|
+
}
|
|
472
|
+
return deptReply({
|
|
473
|
+
ok: true, network_id: netId, unassigned_agents: unassigned, agents,
|
|
474
|
+
departments: listed.departments.map((d) => ({ ...d, agent_count: counts.get(d.id) ?? 0 })),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (!canManageDepartments(netId)) return writeDeniedReply(netId, "write") as any;
|
|
478
|
+
const actor = { user_id: enforceUserId || "node", username: callerAlias || "agent" };
|
|
479
|
+
if (action === "create") {
|
|
480
|
+
if (!name) return deptReply({ ok: false, error: "invalid_department_name" });
|
|
481
|
+
const result = createDepartment(netId, enforceUserId || callerAlias || "mcp", {
|
|
482
|
+
name, ...(parent_id ? { parent_id } : {}), ...(department_id ? { id: department_id } : {}),
|
|
483
|
+
}) as any;
|
|
484
|
+
if (result?.ok === false) return deptFail(result);
|
|
485
|
+
auditDepartment(actor, "department_create", netId, String(result.department?.id ?? ""));
|
|
486
|
+
return deptReply({ ok: true, department: result.department });
|
|
487
|
+
}
|
|
488
|
+
if (action === "update") {
|
|
489
|
+
if (!department_id) return deptReply({ ok: false, error: "department_required" });
|
|
490
|
+
const body: Record<string, unknown> = {};
|
|
491
|
+
if (name !== undefined) body.name = name;
|
|
492
|
+
if (parent_id !== undefined) body.parent_id = parent_id;
|
|
493
|
+
const result = updateDepartment(netId, department_id, body) as any;
|
|
494
|
+
if (result?.ok === false) return deptFail(result);
|
|
495
|
+
auditDepartment(actor, "department_update", netId, `id=${department_id}`);
|
|
496
|
+
return deptReply({ ok: true, department: result.department });
|
|
497
|
+
}
|
|
498
|
+
if (action === "delete") {
|
|
499
|
+
if (!department_id) return deptReply({ ok: false, error: "department_required" });
|
|
500
|
+
const result = deleteDepartment(netId, department_id) as any;
|
|
501
|
+
if (result?.ok === false) return deptFail(result);
|
|
502
|
+
auditDepartment(actor, "department_delete", netId, `id=${department_id}`);
|
|
503
|
+
return deptReply({ ok: true, deleted: department_id });
|
|
504
|
+
}
|
|
505
|
+
let target = node_id ?? "";
|
|
506
|
+
if (!target && alias) {
|
|
507
|
+
const row = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", netId, resolveCanonicalAlias(netId, alias).alias);
|
|
508
|
+
if (!row) return deptReply({ ok: false, error: "agent_not_found" });
|
|
509
|
+
target = row.node_id;
|
|
510
|
+
}
|
|
511
|
+
if (!target) return deptReply({ ok: false, error: "agent_required" });
|
|
512
|
+
const result = setAgentDepartment(netId, target, department_id === undefined ? null : department_id) as any;
|
|
513
|
+
if (result?.ok === false) return deptFail(result);
|
|
514
|
+
auditDepartment(actor, "department_assign_agent", netId, `${target} -> ${department_id ?? "null"}`);
|
|
515
|
+
return deptReply({ ok: true, node_id: result.node_id, department_id: result.department_id });
|
|
516
|
+
},
|
|
517
|
+
);
|
|
518
|
+
|
|
428
519
|
// SkillHub is a network registry, not a public filesystem. Content is an
|
|
429
520
|
// immutable SKILL.md snapshot; identity comes only from the authenticated
|
|
430
521
|
// MCP principal (never from request fields).
|