@sleep2agi/commhub-server 0.9.0-preview.89 → 0.9.0-preview.91

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/src/tools.ts CHANGED
@@ -14,6 +14,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
14
14
  import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
15
15
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
16
16
  import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
17
+ import { errorBody } from "./requirements-errors.js";
17
18
  import { handleRequirementsRequest } from "./requirements.js";
18
19
  import {
19
20
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
@@ -147,9 +148,9 @@ function scheduledReplyRecipient(taskId: string, networkId: string | null | unde
147
148
  // 方无影响,只是错误形态更一致)。
148
149
  const NODE_ID_ALIAS_FIELDS = {
149
150
  node_id: z.string().min(1).max(200).optional()
150
- .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
151
+ .describe("Child node id (as returned by create_node), in your network."),
151
152
  child_node_id: z.string().min(1).max(200).optional()
152
- .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
153
+ .describe("Deprecated alias of node_id."),
153
154
  } as const;
154
155
 
155
156
  /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
@@ -1638,7 +1639,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1638
1639
  network_id: z.string().max(200).optional().describe("Network scope"),
1639
1640
  parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. When the child task replies the hub will auto-chain the answer to the parent task's originator, so the user sees the final result even if the intermediate session ends."),
1640
1641
  meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
1641
- force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
1642
+ force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
1642
1643
  },
1643
1644
  async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1644
1645
  const effectiveNetId = getNetworkId(netId);
@@ -1971,7 +1972,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1971
1972
  // `attachments` before this schema entry existed would see `ok:true`
1972
1973
  // and never learn the attachments were dropped. Validated by
1973
1974
  // validateAttachments (uploads.ts) — the same helper the REST path uses.
1974
- attachments: z.any().optional().describe("Optional attachment array; parity with send_task's meta.attachments. Each item: {type:'file', file_id, name?, mime?, size?}. Persisted into tasks.meta_json + inbox.meta_json."),
1975
+ attachments: z.any().optional().describe("Attachments, like send_task's meta.attachments: [{type:'file', file_id, name?, mime?, size?}]."),
1975
1976
  // #507 — optional structured metadata (parity with send_task L837). If
1976
1977
  // both `attachments` (top-level) and `meta.attachments` are supplied,
1977
1978
  // top-level wins (same rule as REST /api/task L2101).
@@ -2358,7 +2359,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2358
2359
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
2359
2360
  from_session: z.string().max(200).optional(),
2360
2361
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
2361
- force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
2362
+ force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
2362
2363
  },
2363
2364
  async ({ task_id, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2364
2365
  const effectiveNetId = getNetworkId(netId);
@@ -2577,7 +2578,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2577
2578
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
2578
2579
  from_session: z.string().max(200).optional(),
2579
2580
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
2580
- force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
2581
+ force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
2581
2582
  },
2582
2583
  async ({ task_id, new_alias, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2583
2584
  const effectiveNetId = getNetworkId(netId);
@@ -3508,7 +3509,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3508
3509
  "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.",
3509
3510
  {
3510
3511
  ...NODE_ID_ALIAS_FIELDS,
3511
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3512
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
3512
3513
  network_id: z.string().max(200).optional(),
3513
3514
  },
3514
3515
  async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
@@ -3519,7 +3520,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3519
3520
  "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.",
3520
3521
  {
3521
3522
  ...NODE_ID_ALIAS_FIELDS,
3522
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3523
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
3523
3524
  content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3524
3525
  network_id: z.string().max(200).optional(),
3525
3526
  },
@@ -3531,7 +3532,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3531
3532
  "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3532
3533
  {
3533
3534
  ...NODE_ID_ALIAS_FIELDS,
3534
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3535
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
3535
3536
  network_id: z.string().max(200).optional(),
3536
3537
  },
3537
3538
  async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
@@ -3542,7 +3543,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3542
3543
  "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}.",
3543
3544
  {
3544
3545
  ...NODE_ID_ALIAS_FIELDS,
3545
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3546
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
3546
3547
  name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
3547
3548
  network_id: z.string().max(200).optional(),
3548
3549
  },
@@ -3554,7 +3555,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3554
3555
  "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.",
3555
3556
  {
3556
3557
  ...NODE_ID_ALIAS_FIELDS,
3557
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3558
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
3558
3559
  path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
3559
3560
  network_id: z.string().max(200).optional(),
3560
3561
  },
@@ -3566,7 +3567,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3566
3567
  "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.",
3567
3568
  {
3568
3569
  ...NODE_ID_ALIAS_FIELDS,
3569
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3570
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
3570
3571
  path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
3571
3572
  network_id: z.string().max(200).optional(),
3572
3573
  },
@@ -3578,7 +3579,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3578
3579
  "Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
3579
3580
  {
3580
3581
  ...NODE_ID_ALIAS_FIELDS,
3581
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported logs_capable."),
3582
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
3582
3583
  lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
3583
3584
  level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
3584
3585
  grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
@@ -5839,7 +5840,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5839
5840
  const data = res ? await res.json() : { ok: false, error: "not_found" };
5840
5841
  return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
5841
5842
  };
5842
- const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
5843
+ // #473 —— 人员字段除了 id,也可以按名字:{kind:'user', username} / {kind:'node', alias},在任务所在的网络里解析
5844
+ // (requirements.ts personRef;找不到 / 别名对上多个 → 400 + hint)。给了 id 就只看 id。
5845
+ const reqPerson = z.object({
5846
+ kind: z.enum(["user", "node"]),
5847
+ // #476 —— 这三格的说明不写在这里:reqPerson 在 3 个工具 × 3 个字段里展开,每份都复制一遍(~1.6 KB)。
5848
+ // 写法在 owner / agent_owner / participants 的 describe 里各说一次。
5849
+ id: z.string().min(1).max(200).optional(),
5850
+ username: z.string().min(1).max(100).optional(),
5851
+ alias: z.string().min(1).max(200).optional(),
5852
+ });
5843
5853
  const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
5844
5854
  const reqFields = {
5845
5855
  tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
@@ -5851,15 +5861,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5851
5861
  description: z.string().max(20_000).optional().describe("markdown"),
5852
5862
  checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
5853
5863
  project_id: z.string().max(200).nullable().optional(),
5854
- owner: reqPerson.nullable().optional().describe("负责人 (a person): must be {kind:'user', id}; a node here is rejected with 400 owner_must_be_human — use agent_owner for nodes"),
5855
- agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
5856
- participants: z.array(reqPerson).max(100).optional(),
5864
+ owner: reqPerson.nullable().optional().describe("负责人 (a person): {kind:'user', id | username} (ids / usernames from requirements_people; names resolve in the task's network); a node is rejected with owner_must_be_human — use agent_owner"),
5865
+ agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id | alias}"),
5866
+ participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole list (written like owner / agent_owner; omitted people are removed, [] clears). To add or remove a few, use participants_add / participants_remove on requirements_update."),
5857
5867
  external_url: z.string().max(500).nullable().optional(),
5858
5868
  parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
5859
5869
  };
5860
5870
  const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
5861
5871
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5862
- const REQ_ID_DESC = "requirement id (req_…) or its short number \"#N\" (e.g. \"#42\", per network; a user token that sees several networks must also pass network_id, else 409 ambiguous_seq)";
5872
+ // #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
5873
+ // 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
5874
+ const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column; must match it if both are sent");
5875
+ const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
5876
+ if (args.status === undefined) return call(args);
5877
+ if (args.column !== undefined && args.column !== args.status) return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ ...errorBody("status_conflicts_with_column"), status: 400 }) }] });
5878
+ return call({ ...args, column: args.status });
5879
+ };
5880
+ const reqPeopleDelta = (verb: string) => z.array(reqPerson).max(100).optional().describe(`${verb === "add" ? "Add to" : "Remove from"} the participant list, keeping the rest (atomic; not with participants).`);
5881
+ const REQ_ID_DESC = "requirement id (req_…) or short number \"#N\" (per network; with several networks also pass network_id)";
5863
5882
 
5864
5883
  // #471 —— MCP 的列表默认省流:summary 视图 + 50 张一页(REST 的默认 full + 500 不变,App 靠它)。
5865
5884
  // 生产实测 122 张卡:旧默认(full、500)148 KB,summary 81 KB —— 一次调用就塞满 Agent 的上下文。
@@ -5877,6 +5896,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5877
5896
  external_ref: z.string().max(200).optional(),
5878
5897
  include_archived: z.boolean().optional(),
5879
5898
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5899
+ department_id: z.string().max(64).optional().describe("owner (or its Agent's owner) in this department or below"),
5880
5900
  top_level: z.boolean().optional(), q: z.string().max(200).optional(),
5881
5901
  limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
5882
5902
  cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
@@ -5887,7 +5907,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5887
5907
  server.registerTool(
5888
5908
  "requirements_list",
5889
5909
  {
5890
- description: `List requirement tasks in your network. Defaults (MCP): view='summary' and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page, newest first — about 0.5 KB per task, so a page stays small. has_more + next_cursor → pass cursor for the next page. Summary rows drop description and checklist bodies (has_description, checklist_count instead); for full text read one task with requirements_get, or pass view='full' (several KB per task — keep limit small). Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact label), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). limit ≤ 1000. Each task carries children {total, done}. changes=true (needs updated_since) returns only tasks changed since then, archived ones included, plus deleted ids and server_time to pass as the next updated_since. Unknown parameters are rejected (-32602) with the list of valid ones.`,
5910
+ description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}. changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
5891
5911
  // Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
5892
5912
  inputSchema: z.strictObject(reqListShape, {
5893
5913
  error: (iss: any) => iss.code === "unrecognized_keys"
@@ -5897,7 +5917,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5897
5917
  },
5898
5918
  async (args: any) => {
5899
5919
  const q = new URLSearchParams();
5900
- for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "tag", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5920
+ for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "tag", "updated_since", "external_ref", "parent_id", "department_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5901
5921
  if (!q.has("view")) q.set("view", "summary");
5902
5922
  if (!q.has("limit")) q.set("limit", String(REQ_LIST_MCP_DEFAULT_LIMIT));
5903
5923
  if (args.include_archived) q.set("include_archived", "1");
@@ -5907,6 +5927,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5907
5927
  },
5908
5928
  );
5909
5929
 
5930
+ // #473 —— Agent 查人:紧凑通讯录(requirements-people.ts)。严格参数,同 requirements_list(#471)。
5931
+ const reqPeopleShape = {
5932
+ network_id: z.string().max(200).optional(),
5933
+ q: z.string().min(1).max(100).optional().describe("case-insensitive substring of username, display name, department or Agent alias"),
5934
+ limit: z.number().int().min(1).max(200).optional().describe("people per page, default 50"),
5935
+ offset: z.number().int().min(0).optional().describe("next_offset from the previous page"),
5936
+ };
5937
+ const reqPeopleKeys = Object.keys(reqPeopleShape);
5938
+ server.registerTool(
5939
+ "requirements_people",
5940
+ {
5941
+ description: "Look up the people in your network and the Agents they own, to fill task person fields. Each row: user_id, username, display_name (\"\" if not set), role, department {id, name} or null, agents [{node_id, alias}]; Agents with no owner in this network are listed in agents_without_owner (first page only). q filters by username / display name / department / Agent alias (case-insensitive substring). 50 people per page (limit ≤ 200); has_more + next_offset → pass offset. Agents you are not granted are left out. Task person fields take these ids, or {kind:'user', username} / {kind:'node', alias}.",
5942
+ inputSchema: z.strictObject(reqPeopleShape, {
5943
+ error: (iss: any) => iss.code === "unrecognized_keys"
5944
+ ? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqPeopleKeys.join(", ")}`
5945
+ : undefined,
5946
+ }) as any,
5947
+ },
5948
+ async (args: any) => {
5949
+ const q = new URLSearchParams();
5950
+ for (const k of ["q", "limit", "offset"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5951
+ return requirementsCall("GET", `/api/requirements/people/directory${q.size ? `?${q}` : ""}`, args.network_id);
5952
+ },
5953
+ );
5954
+
5910
5955
  server.tool(
5911
5956
  "requirements_get",
5912
5957
  "Get one requirement task by id or by its short number \"#N\" (with description, checklist, owners, project, external_ref, seq).",
@@ -5916,16 +5961,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5916
5961
 
5917
5962
  server.tool(
5918
5963
  "requirements_create",
5919
- "Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
5920
- { network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
5921
- async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
5964
+ "Create a requirement task. name is required. column (or its alias status) = pool / doing / done. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
5965
+ { network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
5966
+ async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
5922
5967
  );
5923
5968
 
5924
5969
  server.tool(
5925
5970
  "requirements_update",
5926
- "Patch a requirement task (id or \"#N\"); omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5927
- { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5928
- async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
5971
+ "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done. 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).",
5972
+ { 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() },
5973
+ async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
5929
5974
  );
5930
5975
 
5931
5976
  server.tool(
@@ -5937,14 +5982,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5937
5982
 
5938
5983
  server.tool(
5939
5984
  "requirements_upsert_by_external_ref",
5940
- "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 status — are kept). Returns { requirement, created }.",
5941
- { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
5942
- async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
5985
+ "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 }.",
5986
+ { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, status: reqStatus, archived: z.boolean().optional() },
5987
+ async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(a, REQ_WRITE_KEYS))),
5943
5988
  );
5944
5989
 
5945
5990
  server.tool(
5946
5991
  "projects_list",
5947
- "List requirement projects in your network (id, name, color, sort, archived). Manage them with projects_create / projects_update.",
5992
+ "List requirement projects in your network (id, name, color, sort, archived). Node (Agent) tokens can only read projects; creating / changing them (projects_create / projects_update) needs a person's token.",
5948
5993
  { network_id: z.string().max(200).optional() },
5949
5994
  async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
5950
5995
  );
@@ -5954,7 +5999,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5954
5999
  // write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
5955
6000
  server.tool(
5956
6001
  "projects_create",
5957
- "Create a requirement project in your network (same as 管理项目 → 新建 in the app). name 1–40 characters, unique in the network; color #RRGGBB (default: next palette colour); sort integer. Returns { project }. Refused (403) for task-scoped members and read-only roles.",
6002
+ "Create a requirement project in your network (same as 管理项目 → 新建 in the app). name 1–40 characters, unique in the network; color #RRGGBB (default: next palette colour); sort integer. Returns { project }. Needs a person's token: node (Agent) tokens can only read projects (403 user_token_required). Also refused (403) for task-scoped members and read-only roles.",
5958
6003
  {
5959
6004
  network_id: z.string().max(200).optional(),
5960
6005
  name: z.string().min(1).max(40),
@@ -5966,7 +6011,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5966
6011
 
5967
6012
  server.tool(
5968
6013
  "projects_update",
5969
- "Rename, recolour, reorder, archive or unarchive a requirement project (same as 管理项目 in the app). Only the fields you pass change. archived=true hides it from pickers (its tasks keep project_id); archived=false brings it back. Returns { project }. Refused (403) for task-scoped members and read-only roles.",
6014
+ "Rename, recolour, reorder, archive or unarchive a requirement project (same as 管理项目 in the app). Only the fields you pass change. archived=true hides it from pickers (its tasks keep project_id); archived=false brings it back. Returns { project }. Needs a person's token: node (Agent) tokens can only read projects (403 user_token_required). Also refused (403) for task-scoped members and read-only roles.",
5970
6015
  {
5971
6016
  network_id: z.string().max(200).optional(),
5972
6017
  id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
@@ -5978,17 +6023,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5978
6023
  async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived"])),
5979
6024
  );
5980
6025
 
5981
- server.tool(
6026
+ // #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
6027
+ const REQ_EVENTS_NETWORK_DEFAULT_LIMIT = 50;
6028
+ const reqEventsShape = {
6029
+ network_id: z.string().max(200).optional(),
6030
+ requirement_id: z.string().min(1).max(200).optional().describe(REQ_ID_DESC),
6031
+ since: z.string().max(40).optional().describe("ISO; only events after it"),
6032
+ limit: z.number().int().min(1).max(500).optional(),
6033
+ cursor: z.string().max(20).optional().describe("next_cursor from the previous page (older events)"),
6034
+ };
6035
+ const reqEventsKeys = Object.keys(reqEventsShape);
6036
+ server.registerTool(
5982
6037
  "requirements_events",
5983
- "Read the task activity timeline (the app's 动态): field-level changes — who, when, old → new — newest first. Pass requirement_id (req_… or \"#N\") for one task, or omit it for the whole network. since (ISO) = only changes after it; limit 1–500 (default 200); next_cursor → pass as cursor for the next page; server_time → use as the next since. Same visibility as requirements_list: tasks you can't see (and hidden Agents) are left out.",
5984
6038
  {
5985
- network_id: z.string().max(200).optional(),
5986
- requirement_id: z.string().min(1).max(200).optional(),
5987
- since: z.string().max(40).optional(),
5988
- limit: z.number().int().min(1).max(500).optional(),
5989
- cursor: z.string().max(20).optional(),
6039
+ description: `Read the task activity timeline (the app's 动态), newest first: field-level changes (who, when, old → new) and comments (kind=comment, text in new.text; add one with requirements_comment). With requirement_id (req_… or "#N"): that task's events (limit default 200). Without it: the whole network, ${REQ_EVENTS_NETWORK_DEFAULT_LIMIT} per page by default. limit ≤ 500; next_cursor → pass as cursor for older events; server_time → use as the next since. Same visibility as requirements_list. Unknown parameters are rejected (-32602).`,
6040
+ inputSchema: z.strictObject(reqEventsShape, {
6041
+ error: (iss: any) => iss.code === "unrecognized_keys"
6042
+ ? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqEventsKeys.join(", ")}`
6043
+ : undefined,
6044
+ }) as any,
5990
6045
  },
5991
- async (args) => {
6046
+ async (args: any) => {
5992
6047
  let card = args.requirement_id;
5993
6048
  // "#N" → the card's id, resolved exactly like requirements_get (same scope; a card you can't see is a 404 here too).
5994
6049
  if (card && /^#\d+$/.test(card)) {
@@ -6001,10 +6056,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6001
6056
  if (card) q.set("requirement_id", card);
6002
6057
  if (args.since) q.set("since", args.since);
6003
6058
  if (args.limit !== undefined) q.set("limit", String(args.limit));
6059
+ else if (!card) q.set("limit", String(REQ_EVENTS_NETWORK_DEFAULT_LIMIT));
6004
6060
  if (args.cursor) q.set("cursor", args.cursor);
6005
6061
  return requirementsCall("GET", `/api/requirements/events${q.size ? `?${q}` : ""}`, args.network_id);
6006
6062
  },
6007
6063
  );
6064
+
6065
+ // #474(MCP 任务生命周期测试报告问题 5):评论 / 进展。以前只能 requirements_get 读全文、再整段改写 description ——
6066
+ // 两次调用,并发时覆盖别人刚写的描述。这条只追加,进「动态」(requirements_events kind=comment),不改任务本身。
6067
+ server.tool(
6068
+ "requirements_comment",
6069
+ "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 }.",
6070
+ {
6071
+ id: z.string().min(1).max(200).describe(REQ_ID_DESC),
6072
+ text: z.string().min(1).max(4000).describe("the comment (markdown), 1–4000 characters"),
6073
+ network_id: z.string().max(200).optional(),
6074
+ },
6075
+ async ({ id, text, network_id }) => requirementsCall("POST", `/api/requirements/${encodeURIComponent(id)}/comments`, network_id, { text }),
6076
+ );
6008
6077
  }
6009
6078
 
6010
6079
  // ────────────────────────────────────────────────────────────────────