@sleep2agi/commhub-server 0.9.0-preview.156 → 0.9.0-preview.157

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.156",
3
+ "version": "0.9.0-preview.157",
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",
@@ -259,6 +259,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
259
259
  request_adopt_node: "human_only", unadopt_node: "human_only",
260
260
  get_adopt_request: "always", ack_adopt_request: "always",
261
261
  send_desktop_message: "always", // 发给人的私信,不是派活
262
+ requirement_tags: "requirements",
262
263
  // 读
263
264
  get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
264
265
  list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
@@ -381,3 +381,45 @@ describe("改父级 reparent —— 被拒绝时不留副作用", () => {
381
381
  expect(after?.registered ?? false).toBe(false);
382
382
  });
383
383
  });
384
+
385
+ // ── MCP 面(#2204 缺口 2)──
386
+ // 一个工具(action 分派)而不是 list + op 两个:tools/list 字节预算是硬约束,见 tools.ts 的注释。
387
+ describe("requirement_tags (MCP)", () => {
388
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
389
+ const r = await fetch(`${BASE}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
390
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
391
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
392
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
393
+ try { return JSON.parse(envelope.result.content[0].text); } catch { return { mcpError: envelope.error ?? envelope.result }; }
394
+ }
395
+ const nodes = async (token: string) => (await send(token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
396
+
397
+ test("action=list 返回标签树(含 parent/depth/registered);action=reparent 走同一条 REST 语义", async () => {
398
+ expect((await send(admin.token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name: "MCP父", parent_id: null })).status).toBe(201);
399
+ expect((await send(admin.token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name: "MCP子", parent_id: null })).status).toBe(201);
400
+
401
+ const listed = await mcp(admin.token, "requirement_tags", { action: "list" });
402
+ const rows = listed.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
403
+ expect(rows.find(node => node.name === "MCP父")).toMatchObject({ parent_id: null, depth: 1, registered: true });
404
+ expect(rows.find(node => node.name === "MCP子")).toMatchObject({ parent_id: null, depth: 1 });
405
+
406
+ const moved = await mcp(admin.token, "requirement_tags", { action: "reparent", tag: "MCP子", parent: "MCP父" });
407
+ expect(moved).toMatchObject({ ok: true, op: "reparent", affected: 0, reparented: 1 });
408
+ expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父", depth: 2 });
409
+
410
+ // 与 REST 同一套错误码:不传 parent 键 ≠ 升到顶层
411
+ const missing = await mcp(admin.token, "requirement_tags", { action: "reparent", tag: "MCP子" });
412
+ expect(missing.error).toBe("invalid_tag_parent_id");
413
+ expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父" });
414
+ });
415
+
416
+ test("节点令牌:能 list,但写操作被拒(403 permission_denied),树不变", async () => {
417
+ const listed = await mcp(nodeToken, "requirement_tags", { action: "list" });
418
+ expect(Array.isArray(listed.nodes)).toBe(true);
419
+ const write = await mcp(nodeToken, "requirement_tags", { action: "reparent", tag: "MCP子", parent: null });
420
+ expect(write.status).toBe(403);
421
+ // 写操作先过「要人的令牌」这道闸(tags_write),再到 owner/admin 判定;节点令牌停在前一道。
422
+ expect(["user_token_required", "permission_denied"]).toContain(write.error);
423
+ expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父" });
424
+ });
425
+ });
@@ -211,6 +211,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
211
211
  // assign_agent 合并成一个,尽量小)。实测 node 64,112 B / user 60,117 B,比原上限
212
212
  // 分别高 412 / 279 B —— 这是有意的功能增长(组织架构可由 Agent 直接调),不是文案偷偷变长。
213
213
  // 留约 400 B 余量;再要加工具时请先合并动作或砍描述,别连续抬上限。
214
+ // #2204(2026-10-25):标签管理给 MCP。为了**不抬上限**,做了两件事:
215
+ // ① 合并成一个工具 `requirement_tags`(action 分派 list/rename/merge/delete/color/reparent),
216
+ // 而不是 list + op 两个(照 #1894 `departments` 的先例);
217
+ // ② 砍掉一批冗长描述里重复的解释性从句(每条约 20–100 B,信息点全部保留)。
218
+ // 实测(含新工具):all 97 / 86,020 B;node 64 / 64,464 B;user 64 / 59,680 B —— 三个上限**一个都没动**。
219
+ // 下次再加工具,请先合并动作或砍描述,不要直接改这三个数。
214
220
  const CEILINGS = { node: 64_500, user: 60_500, all: 86_500 } as const;
215
221
  test("node / user / all", async () => {
216
222
  const node = await listTools(T.node_normal);
@@ -43,7 +43,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
43
43
  get_probe_request: "protocol", ack_probe_request: "protocol",
44
44
  get_opencode_catalog_request: "protocol", ack_opencode_catalog: "protocol",
45
45
  // ── 人:节点令牌一律 user_token_required(项目管理)/ node_token_cannot_browse_files / node_token_cannot_read_logs ──
46
- projects_create: "user", projects_update: "user",
46
+ projects_create: "user", projects_update: "user", requirement_tags: "both",
47
47
  read_opencode_catalog: "user", get_opencode_catalog: "user",
48
48
  list_node_files: "user", read_node_file: "user", tail_node_logs: "user",
49
49
  // ── 两边都能调 ──
package/src/tools.ts CHANGED
@@ -1993,7 +1993,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1993
1993
 
1994
1994
  server.tool(
1995
1995
  "send_task",
1996
- "Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead (open tasks already on the target), target_busy, est_wait_minutes (rough, null = no history) and, when the queue is long, a warning: queued, never refused — consider an idle node.",
1996
+ "Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead, target_busy, est_wait_minutes (null = no history) and, when the queue is long, a warning. Always queued, never refused.",
1997
1997
  {
1998
1998
  alias: z.string().min(1).max(200).describe("Target session alias"),
1999
1999
  task: z.string().min(1).max(TASK_CONTENT_MAX).describe("Task content"),
@@ -2844,7 +2844,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2844
2844
  );
2845
2845
  server.tool(
2846
2846
  "send_peer_reply",
2847
- "Atomically finalize one node-owned task and enqueue one no-response result when the exact recipient advertises peer_reply_inbox_capable. Stored capability survives transient SSE disconnects; legacy peers return peer_reply_unsupported with zero writes.",
2847
+ "Atomically finalize one node-owned task and enqueue one no-response result when the recipient advertises peer_reply_inbox_capable. Stored capability mismatch -> refused, not silently degraded.",
2848
2848
  replyToolSchema,
2849
2849
  (args) => handleReply(args, true),
2850
2850
  );
@@ -3465,7 +3465,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3465
3465
 
3466
3466
  server.tool(
3467
3467
  "update_node_config",
3468
- "Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
3468
+ "Set the per-node config (model + flags) and push a doorbell; the node pulls, validates and applies (hot or restart per field). RFC-024.",
3469
3469
  {
3470
3470
  ...NODE_ID_ALIAS_FIELDS,
3471
3471
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
@@ -4034,7 +4034,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4034
4034
 
4035
4035
  server.tool(
4036
4036
  "read_node_rules_file",
4037
- "Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
4037
+ "Ask a node to send back its rules file (CLAUDE.md for claude, else AGENTS.md) from its work dir. No path argument. Poll get_rules_file_result with the returned request_id. app#225.",
4038
4038
  {
4039
4039
  ...NODE_ID_ALIAS_FIELDS,
4040
4040
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
@@ -4045,7 +4045,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4045
4045
 
4046
4046
  server.tool(
4047
4047
  "write_node_rules_file",
4048
- "Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
4048
+ "Ask a node to write a managed block into its rules file (CLAUDE.md / AGENTS.md). Poll get_rules_file_result. User logins only (node owner or network owner/admin).",
4049
4049
  {
4050
4050
  ...NODE_ID_ALIAS_FIELDS,
4051
4051
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
@@ -4057,7 +4057,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4057
4057
 
4058
4058
  server.tool(
4059
4059
  "list_node_skills",
4060
- "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description; team skills also include origin \"team\"). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…], roots, warnings}.",
4060
+ "Ask a node for the skills its runtime loads (name, scope project|user|system, display path, description; team skills add origin \"team\"). No path. Poll get_rules_file_result: {skills:[...], roots, warnings}.",
4061
4061
  {
4062
4062
  ...NODE_ID_ALIAS_FIELDS,
4063
4063
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -4068,7 +4068,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4068
4068
 
4069
4069
  server.tool(
4070
4070
  "read_node_skill",
4071
- "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content, origin?}. origin is \"team\" only for a team skill.",
4071
+ "One skill's SKILL.md by name; the node resolves it under its own skills roots (no path). Poll get_rules_file_result: {name, scope, path_rel, description, content, origin?}; origin=\"team\" only for a team skill.",
4072
4072
  {
4073
4073
  ...NODE_ID_ALIAS_FIELDS,
4074
4074
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -4080,7 +4080,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4080
4080
 
4081
4081
  server.tool(
4082
4082
  "list_node_files",
4083
- "Ask a node to list one directory level of its work dir (project folder view, read-only). `path` is relative to the work dir (default: the root); absolute paths and .. are refused. Poll get_rules_file_result — content is JSON {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
4083
+ "List one level of the node's work dir (read-only). `path` relative (default root); absolute and .. refused. Poll get_rules_file_result: {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
4084
4084
  {
4085
4085
  ...NODE_ID_ALIAS_FIELDS,
4086
4086
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
@@ -4092,7 +4092,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4092
4092
 
4093
4093
  server.tool(
4094
4094
  "read_node_file",
4095
- "Ask a node for one text file under its work dir (project folder view, read-only, 256 KiB cap; binary / too large → size only; secret-looking files → name only). `path` is relative to the work dir. Poll get_rules_file_result — content is JSON {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
4095
+ "One text file under the node's work dir (read-only, 256 KiB cap; binary / too large -> size only; secret-looking -> name only). `path` relative. Poll get_rules_file_result: {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
4096
4096
  {
4097
4097
  ...NODE_ID_ALIAS_FIELDS,
4098
4098
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
@@ -4104,7 +4104,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4104
4104
 
4105
4105
  server.tool(
4106
4106
  "tail_node_logs",
4107
- "Tail of a node's own agent-node run log (read-only, redacted on the node). No path argument: it reads only its own log directory. User logins only (node owner or network owner/admin). Poll get_rules_file_result; JSON {files, lines, truncated, matched}; handed out once, then purged.",
4107
+ "Tail of the node's own run log (read-only, redacted on the node). No path argument. User logins only (node owner or network owner/admin). Poll get_rules_file_result: {files, lines, truncated, matched}; handed out once, then purged.",
4108
4108
  {
4109
4109
  ...NODE_ID_ALIAS_FIELDS,
4110
4110
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
@@ -4217,7 +4217,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4217
4217
 
4218
4218
  server.tool(
4219
4219
  "get_rules_file_result",
4220
- "Poll the outcome of a read_node_rules_file / write_node_rules_file request. Returns status pending|in_progress|done|failed|timeout, the file name the node used, and (for reads) the content. app#225.",
4220
+ "Poll a read_node_rules_file / write_node_rules_file / node-file / node-log request. Returns status pending|in_progress|done|failed|timeout plus the file name and (reads) content. app#225.",
4221
4221
  { request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
4222
4222
  async ({ request_id: requestId, network_id: clientNetId }) => {
4223
4223
  const effectiveNetId = getNetworkId(clientNetId);
@@ -4298,7 +4298,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4298
4298
 
4299
4299
  server.tool(
4300
4300
  "restart_node",
4301
- "Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
4301
+ "Restart a node without changing config (RFC-024). Network-scoped (SEC-1); member+ suffices — not privilege elevation.",
4302
4302
  {
4303
4303
  ...NODE_ID_ALIAS_FIELDS,
4304
4304
  network_id: z.string().max(200).optional(),
@@ -4930,7 +4930,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4930
4930
  // the policy and the runtime's session capability (see clone-node.ts).
4931
4931
  server.tool(
4932
4932
  "clone_node",
4933
- "Clone a daemon child into a new node on the same daemon. New name and new token. copy_session defaults to true. workdir_policy new_empty (default) or share_source. Hub #938.",
4933
+ "Clone a daemon child into a new node on the same daemon: new name + token. copy_session defaults true; workdir_policy new_empty (default) or share_source. Hub #938.",
4934
4934
  {
4935
4935
  source_node_id: z.string().min(1).max(200),
4936
4936
  name: z.string().min(1).max(256),
@@ -5720,7 +5720,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5720
5720
 
5721
5721
  server.tool(
5722
5722
  "delete_node",
5723
- "Stop child + revoke ntok + delete hub row + (default) backup config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must equal the node's alias. daemon_node_id is auto-resolved when omitted. RFC-027 §2.2.",
5723
+ "Stop child + revoke token + delete hub row + (default) back up config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must match. daemon_node_id auto-resolved. RFC-027 §2.2.",
5724
5724
  {
5725
5725
  ...NODE_ID_ALIAS_FIELDS,
5726
5726
  daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
@@ -6218,7 +6218,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6218
6218
  // §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
6219
6219
  server.tool(
6220
6220
  "upsert_network_secret",
6221
- "Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
6221
+ "Create or replace one vault secret by key (value never read back). RFC-028.",
6222
6222
  {
6223
6223
  key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
6224
6224
  value: z.string().min(1).max(16 * 1024),
@@ -6888,14 +6888,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6888
6888
 
6889
6889
  server.tool(
6890
6890
  "requirements_create",
6891
- "Create a requirement task. name is required. column (or its alias status) = pool / doing / done / abandoned. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
6891
+ "Create a task; name required. column (alias status) = pool/doing/done/abandoned. Duplicate external_ref -> 409 external_ref_exists with existing_id; use requirements_upsert_by_external_ref to sync.",
6892
6892
  { network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
6893
6893
  async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
6894
6894
  );
6895
6895
 
6896
6896
  server.tool(
6897
6897
  "requirements_update",
6898
- "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done / abandoned. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
6898
+ "Patch a task (id or \"#N\"); omitted fields keep their value. column (alias status) = pool/doing/done/abandoned. participants REPLACES the whole list (use participants_add / _remove to keep others). archived=true hides it; agents cannot delete.",
6899
6899
  { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
6900
6900
  async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
6901
6901
  );
@@ -6909,7 +6909,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6909
6909
 
6910
6910
  server.tool(
6911
6911
  "requirements_upsert_by_external_ref",
6912
- "Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including column / status — are kept). Returns { requirement, created }.",
6912
+ "Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or update the one already bound to it. Returns { requirement, created }.",
6913
6913
  { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, status: reqStatus, archived: z.boolean().optional() },
6914
6914
  async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(a, REQ_WRITE_KEYS))),
6915
6915
  );
@@ -6926,7 +6926,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6926
6926
  // write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
6927
6927
  server.tool(
6928
6928
  "projects_create",
6929
- "Create a requirement project (管理项目 → 新建). name 1–40, unique; color #RRGGBB; sort int; parent_id optional (≤3 levels). Returns { project }. node tokens can only read projects (403 user_token_required); task-scoped / read-only members are refused.",
6929
+ "Create a project (管理项目 → 新建). name 1-40, unique; color #RRGGBB; sort int; parent_id optional (<=3 levels). Returns { project }; node tokens can only read projects.",
6930
6930
  {
6931
6931
  network_id: z.string().max(200).optional(),
6932
6932
  name: z.string().min(1).max(40),
@@ -6939,7 +6939,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6939
6939
 
6940
6940
  server.tool(
6941
6941
  "projects_update",
6942
- "Rename, recolour, reorder, archive/unarchive, or move a project under another (parent_id; null = top level, cycles / depth>3 refused). Only the fields you pass change; archived hides it from pickers. Returns { project }. node tokens can only read projects (403); task-scoped / read-only members refused.",
6942
+ "Rename, recolour, reorder, archive, or move a project under another (parent_id; null = top level; cycles / depth>3 refused). Only fields you pass change; node tokens can only read projects.",
6943
6943
  {
6944
6944
  network_id: z.string().max(200).optional(),
6945
6945
  id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
@@ -6952,6 +6952,33 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6952
6952
  async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived", "parent_id"])),
6953
6953
  );
6954
6954
 
6955
+ // #2204 —— 标签管理给 MCP:REST 早就有 rename/merge/delete/color/reparent,但只有人用的 HTTP,
6956
+ // MCP 侧既读不到标签树、也改不了父级。做成**一个**工具(action 分派)而不是 list + op 两个:
6957
+ // tools/list 字节预算是硬约束(all 86,500 / node 64,500 / user 60,500),两个工具会撑破预算,
6958
+ // 而"抬预算"是仓里明令禁止的做法(见 tool-audience-http.test.ts 注释与 #1894 先例)。
6959
+ server.tool(
6960
+ "requirement_tags",
6961
+ "Tags of this network. action=list → name, parent_id (null=root), depth, colour, usage, can_manage. Writes (owner/admin; node tokens 403): rename|merge|delete|color|reparent. reparent moves only the tree edge (cycle / depth>4 refused).",
6962
+ {
6963
+ network_id: z.string().max(200).optional(),
6964
+ action: z.enum(["list", "rename", "merge", "delete", "color", "reparent"]),
6965
+ tag: z.string().min(1).max(20).optional(),
6966
+ from: z.union([z.string().min(1).max(20), z.array(z.string().min(1).max(20)).min(1).max(50)]).optional(),
6967
+ to: z.string().min(1).max(20).optional(),
6968
+ parent: z.string().min(1).max(20).nullable().optional(),
6969
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
6970
+ },
6971
+ async (args) => {
6972
+ if (args.action === "list") return requirementsCall("GET", "/api/requirements/tags", args.network_id);
6973
+ const body: Record<string, unknown> = { op: args.action };
6974
+ if (args.action === "rename" || args.action === "merge") { body.from = args.from; body.to = args.to; }
6975
+ else if (args.action === "delete") body.tag = args.tag;
6976
+ else if (args.action === "color") { body.tag = args.tag; body.color = args.color === undefined ? null : args.color; }
6977
+ else { body.tag = args.tag; if ("parent" in args) body.parent = args.parent; }
6978
+ return requirementsCall("POST", "/api/requirements/tags/ops", args.network_id, body);
6979
+ },
6980
+ );
6981
+
6955
6982
  // #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
6956
6983
  const REQ_EVENTS_NETWORK_DEFAULT_LIMIT = 50;
6957
6984
  const reqEventsShape = {
@@ -6995,7 +7022,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6995
7022
  // 两次调用,并发时覆盖别人刚写的描述。这条只追加,进「动态」(requirements_events kind=comment),不改任务本身。
6996
7023
  server.tool(
6997
7024
  "requirements_comment",
6998
- "Add a comment / progress note to a task (append-only; never rewrites the description). It shows in the task's activity timeline (the app's 动态, requirements_events kind=comment, text in new.text) under your name. text: markdown, 1–4000 characters. Anyone who can see the task and can write in the network (not read-only) may comment. Returns { event }.",
7025
+ "Append a comment / progress note to a task (**append-only**; never rewrites the description). Shows in requirements_events (kind=comment, text in new.text). text: markdown 1-4000. Anyone who can see the task and write in the network may comment. Returns { event }.",
6999
7026
  {
7000
7027
  id: z.string().min(1).max(200).describe(REQ_ID_DESC),
7001
7028
  text: z.string().min(1).max(4000).describe("the comment (markdown), 1–4000 characters"),