@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.68

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
@@ -8,7 +8,10 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
9
  import { pendingInboxCount } from "./inbox-count.js";
10
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
11
- import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
11
+ import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
12
+ import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
13
+ import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
14
+ import { handleRequirementsRequest } from "./requirements.js";
12
15
  import {
13
16
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
14
17
  validateName as validateChildName,
@@ -128,7 +131,42 @@ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }
128
131
  return { ok: true, node_id: id };
129
132
  }
130
133
 
134
+ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[], _userId: string): void {
135
+ const denied = (tool: string) => ({
136
+ content: [{
137
+ type: "text" as const,
138
+ text: JSON.stringify({
139
+ ok: false,
140
+ error: "agent_access_restricted",
141
+ message: `${tool} is not available to restricted members in this network; pass network_id of a network where you are not restricted, or ask a network admin for access`,
142
+ }),
143
+ }],
144
+ });
145
+ const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
146
+ if (!RESTRICTED_MEMBER_TOOLS.has(name)) {
147
+ const args = callArgs[0];
148
+ const raw = args && typeof args === "object" ? (args as Record<string, unknown>).network_id : undefined;
149
+ const net = typeof raw === "string" && raw.trim() ? raw.trim() : null;
150
+ if (!net || restrictedNets.includes(net)) return denied(name);
151
+ }
152
+ return handler(...callArgs);
153
+ };
154
+ const anyServer = server as any;
155
+ for (const method of ["tool", "registerTool"] as const) {
156
+ const original = anyServer[method].bind(server);
157
+ anyServer[method] = (...regArgs: any[]) => {
158
+ const last = regArgs.length - 1;
159
+ if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
160
+ return original(...regArgs);
161
+ };
162
+ }
163
+ }
164
+
131
165
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
166
+ // 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
167
+ // 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
168
+ const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
169
+ if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
132
170
  // Default from_session for outbound tools — extracted from the calling
133
171
  // token's binding (ntok_ → node alias, utok_ → username). Without this,
134
172
  // an agent's send_task call always claimed from='hub' and peer agents
@@ -175,6 +213,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
175
213
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
176
214
  return canRestWriteNetwork(mcpAuthCtx, netId, false);
177
215
  };
216
+ // 人类侧写入(私信):不看 Agent 授权,受限成员也可以。
217
+ const canWriteHuman = (effectiveNetworkId?: string | null): boolean =>
218
+ canRestWriteNetworkAsHuman(mcpAuthCtx, enforceNetworkId ?? effectiveNetworkId ?? null, false);
219
+ // 给 Agent 发任务 / 消息的准入。不受限:原判据。受限网络里:目标必须是授权且 can_message 的 Agent,
220
+ // 发件人固定为自己的用户名;目标不存在与没授权返回同一个错误,不留 alias 探测差异。
221
+ const agentSendDenied = (effectiveNetworkId: string | null, alias: string, clientFrom: string | undefined, action: "send_task" | "write", meta?: unknown) => {
222
+ if (!effectiveNetworkId || !restrictedNets.includes(effectiveNetworkId)) {
223
+ return canWrite(effectiveNetworkId) ? null : writeDeniedReply(effectiveNetworkId, action);
224
+ }
225
+ if (!canWriteHuman(effectiveNetworkId)) return writeDeniedReply(effectiveNetworkId, action);
226
+ const requestedFrom = clientFrom?.trim();
227
+ if (requestedFrom && requestedFrom !== callerAlias) {
228
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
229
+ }
230
+ const targetAlias = resolveCanonicalAlias(effectiveNetworkId, alias).alias;
231
+ const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, effectiveNetworkId);
232
+ if (!session || !canMessageAgent(enforceUserId!, effectiveNetworkId, { alias: targetAlias, nodeId: session.node_id })) {
233
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }) }] };
234
+ }
235
+ const rawAttachments = meta && typeof meta === "object" ? (meta as { attachments?: unknown }).attachments : undefined;
236
+ if (restrictedMemberAttachmentsDenied(enforceUserId!, callerAlias ?? "", effectiveNetworkId, rawAttachments)) {
237
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "attachment_not_accessible" }) }] };
238
+ }
239
+ return null;
240
+ };
178
241
 
179
242
  // #517: name the REAL cause. The old catch-all blamed permissions for
180
243
  // what was actually an unresolvable network, sending operators down the
@@ -378,24 +441,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
378
441
  }
379
442
  };
380
443
 
381
- type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
444
+ type ReadScope = RestNetworkScope;
382
445
 
383
446
  // Delegates to the shared membership query (network-scope.ts) — was a
384
447
  // byte-for-byte duplicate of getUserNetworkIds before #517.
385
448
  const getReadableNetworkIds = (): string[] =>
386
449
  enforceUserId ? getUserNetworkIds(enforceUserId) : [];
387
450
 
451
+ // 受限网络挂在 scope.agentRestriction 上:addReadScope(= addNetworkScope)对它们 fail-closed,
452
+ // 逐条审过的工具改用 addAgentNetworkScope / addOwnTrafficScope 按授权放行。
453
+ const withRestriction = (scope: ReadScope): ReadScope => {
454
+ if (!restrictedNets.length || !enforceUserId) return scope;
455
+ const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
456
+ const restricted = restrictedNets.filter((id) => inScope.includes(id));
457
+ return restricted.length ? { ...scope, agentRestriction: { userId: enforceUserId, username: callerAlias ?? "", networkIds: restricted } } : scope;
458
+ };
388
459
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
389
460
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
390
461
  if (enforceNetworkId) {
391
462
  const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
392
- return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
463
+ return role ? { networkId: enforceNetworkId, networkIds: null } : { networkId: null, networkIds: [], denied: "not a member of token network" };
393
464
  }
394
465
  if (clientNetId) {
395
466
  const role = getUserNetworkRole(enforceUserId, clientNetId);
396
- return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
467
+ return role ? withRestriction({ networkId: clientNetId, networkIds: null }) : { networkId: null, networkIds: [], denied: "access denied to requested network" };
397
468
  }
398
- return { networkId: null, networkIds: getReadableNetworkIds() };
469
+ return withRestriction({ networkId: null, networkIds: getReadableNetworkIds() });
399
470
  };
400
471
 
401
472
  // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
@@ -406,20 +477,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
406
477
  // Dispatch (POST /api/task + /api/broadcast). Per
407
478
  // per team rule: grep every write site (MCP tools.ts AND REST index.ts) before adding a guard, and extract the helper into a shared module so both transports import it.
408
479
 
409
- const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
410
- if (scope.networkId) {
411
- sql += ` AND ${column} = ?${params.length + 1}`;
412
- params.push(scope.networkId);
413
- return sql;
414
- }
415
- if (scope.networkIds) {
416
- if (scope.networkIds.length === 0) return `${sql} AND 1=0`;
417
- const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
418
- sql += ` AND ${column} IN (${placeholders})`;
419
- params.push(...scope.networkIds);
420
- }
421
- return sql;
422
- };
480
+ // 默认读作用域:对受限网络 fail-closed(见 network-scope.ts addNetworkScope)。
481
+ const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string =>
482
+ addNetworkScope(sql, params, scope, column);
423
483
 
424
484
  type DeliveryTarget =
425
485
  | { state: "online"; alias: string; session: any }
@@ -1432,7 +1492,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1432
1492
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
1433
1493
  let sql = "SELECT * FROM sessions WHERE 1=1";
1434
1494
  const params: any[] = [];
1435
- sql = addReadScope(sql, params, readScope);
1495
+ sql = addAgentNetworkScope(sql, params, readScope, { alias: "alias", nodeId: "node_id" });
1436
1496
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
1437
1497
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1438
1498
  const aliasFilter = parseAliasFilter(filter_alias);
@@ -1453,7 +1513,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1453
1513
  // under existing callers.
1454
1514
  const summaryParams: any[] = [];
1455
1515
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
1456
- summarySql = addReadScope(summarySql, summaryParams, readScope);
1516
+ summarySql = addAgentNetworkScope(summarySql, summaryParams, readScope, { alias: "alias", nodeId: "node_id" });
1457
1517
  summarySql += " GROUP BY status";
1458
1518
  const summary = db.all(summarySql, ...summaryParams);
1459
1519
 
@@ -1487,7 +1547,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1487
1547
  console.log(`[${ts()}] hub → get_session_status: ${alias}`);
1488
1548
  const sessionParams: any[] = [alias];
1489
1549
  let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
1490
- sessionSql = addReadScope(sessionSql, sessionParams, readScope);
1550
+ sessionSql = addAgentNetworkScope(sessionSql, sessionParams, readScope, { alias: "alias", nodeId: "node_id" });
1491
1551
  const session = db.get(sessionSql, ...sessionParams);
1492
1552
 
1493
1553
  const pendingParams: any[] = [alias];
@@ -1542,8 +1602,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1542
1602
  // existence + ownership of foreign parents via the difference in
1543
1603
  // error codes. Run canWrite first so a viewer ALWAYS gets the
1544
1604
  // same `permission_denied` regardless of parent state.
1545
- if (!canWrite(effectiveNetId)) {
1546
- return writeDeniedReply(effectiveNetId, "send_task");
1605
+ {
1606
+ const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "send_task", meta);
1607
+ if (sendDenied) return sendDenied;
1547
1608
  }
1548
1609
 
1549
1610
  // Resolve parent_task_id: explicit > inferred (caller's most recent
@@ -1783,7 +1844,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1783
1844
  },
1784
1845
  async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1785
1846
  const effectiveNetId = getNetworkId(netId);
1786
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1847
+ { const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "write"); if (sendDenied) return sendDenied; }
1787
1848
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
1788
1849
  const targetAlias = canonical.alias;
1789
1850
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
@@ -2291,7 +2352,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2291
2352
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
2292
2353
  const params: any[] = [task_id];
2293
2354
  let sql = "SELECT * FROM tasks WHERE task_id = ?1";
2294
- sql = addReadScope(sql, params, readScope);
2355
+ sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2295
2356
  const task = db.get<any>(sql, ...params);
2296
2357
  return {
2297
2358
  content: [{
@@ -2360,7 +2421,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2360
2421
  }
2361
2422
  let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
2362
2423
  const params: any[] = [];
2363
- sql = addReadScope(sql, params, readScope);
2424
+ sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2364
2425
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
2365
2426
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
2366
2427
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
@@ -2376,7 +2437,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2376
2437
  // Stats
2377
2438
  const statsParams: any[] = [];
2378
2439
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
2379
- statsSql = addReadScope(statsSql, statsParams, readScope);
2440
+ statsSql = addOwnTrafficScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2380
2441
  statsSql += " GROUP BY status";
2381
2442
  const stats = db.all(statsSql, ...statsParams);
2382
2443
 
@@ -2510,7 +2571,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2510
2571
  if (fromMismatch) return fromMismatch;
2511
2572
  const from_session = defaultFrom(_fromIn);
2512
2573
  const effectiveNetId = getNetworkId(netId);
2513
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2574
+ // 人类 ↔ 人类私信:受限成员也能发(canWriteHuman 不看 Agent 授权),但发件人固定为自己的用户名。
2575
+ if (!canWriteHuman(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2576
+ if (effectiveNetId && restrictedNets.includes(effectiveNetId) && _fromIn?.trim() && _fromIn.trim() !== callerAlias) {
2577
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
2578
+ }
2514
2579
  if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2515
2580
 
2516
2581
  if (!to_user_id && !to_username) {
@@ -2591,8 +2656,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2591
2656
  // OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
2592
2657
  db.run(
2593
2658
  `INSERT INTO user_inbox
2594
- (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
2595
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2659
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id)
2660
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
2596
2661
  ON CONFLICT(message_id) DO NOTHING`,
2597
2662
  [
2598
2663
  messageId,
@@ -2609,6 +2674,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2609
2674
  // 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
2610
2675
  // 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
2611
2676
  meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
2677
+ // 多用户私信:只有人(用户令牌)发的才记发信人 user_id;节点令牌发的是 Agent 消息。
2678
+ !callerTokenIsNetwork && enforceUserId ? enforceUserId : null,
2612
2679
  ],
2613
2680
  );
2614
2681
  });
@@ -5674,6 +5741,110 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5674
5741
  }
5675
5742
  },
5676
5743
  );
5744
+
5745
+ // ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
5746
+ // 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
5747
+ // 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
5748
+ const requirementsCall = async (method: string, path: string, clientNetId?: string | null, body?: Record<string, unknown>) => {
5749
+ const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
5750
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
5751
+ const url = new URL(`http://mcp.internal${path}`);
5752
+ const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
5753
+ const res = await handleRequirementsRequest({
5754
+ req, url,
5755
+ auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
5756
+ isAdmin: false,
5757
+ isNodeToken: callerTokenIsNetwork,
5758
+ scope,
5759
+ });
5760
+ const data = res ? await res.json() : { ok: false, error: "not_found" };
5761
+ return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
5762
+ };
5763
+ const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
5764
+ 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() });
5765
+ const reqFields = {
5766
+ tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
5767
+ name: z.string().min(1).max(80).optional(),
5768
+ priority: z.enum(["high", "normal", "low"]).optional(),
5769
+ column: z.enum(["pool", "doing", "done"]).optional(),
5770
+ due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
5771
+ description: z.string().max(20_000).optional().describe("markdown"),
5772
+ checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
5773
+ project_id: z.string().max(200).nullable().optional(),
5774
+ owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
5775
+ agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
5776
+ participants: z.array(reqPerson).max(100).optional(),
5777
+ external_url: z.string().max(500).nullable().optional(),
5778
+ parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
5779
+ };
5780
+ const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
5781
+ const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5782
+
5783
+ server.tool(
5784
+ "requirements_list",
5785
+ "List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
5786
+ {
5787
+ network_id: z.string().max(200).optional(),
5788
+ status: z.enum(["pool", "doing", "done"]).optional(),
5789
+ project_id: z.string().max(200).optional(),
5790
+ owner: z.string().max(210).optional(),
5791
+ agent_owner: z.string().max(210).optional(),
5792
+ updated_since: z.string().max(40).optional(),
5793
+ external_ref: z.string().max(200).optional(),
5794
+ include_archived: z.boolean().optional(),
5795
+ parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5796
+ top_level: z.boolean().optional(),
5797
+ },
5798
+ async (args) => {
5799
+ const q = new URLSearchParams();
5800
+ for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5801
+ if (args.include_archived) q.set("include_archived", "1");
5802
+ if (args.top_level) q.set("top_level", "1");
5803
+ return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
5804
+ },
5805
+ );
5806
+
5807
+ server.tool(
5808
+ "requirements_get",
5809
+ "Get one requirement task by id (with description, checklist, owners, project, external_ref).",
5810
+ { id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
5811
+ async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
5812
+ );
5813
+
5814
+ server.tool(
5815
+ "requirements_create",
5816
+ "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.",
5817
+ { network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
5818
+ async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
5819
+ );
5820
+
5821
+ server.tool(
5822
+ "requirements_update",
5823
+ "Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5824
+ { id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5825
+ async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
5826
+ );
5827
+
5828
+ server.tool(
5829
+ "requirements_checklist_toggle",
5830
+ "Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
5831
+ { id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5832
+ async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
5833
+ );
5834
+
5835
+ server.tool(
5836
+ "requirements_upsert_by_external_ref",
5837
+ "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 }.",
5838
+ { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
5839
+ async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
5840
+ );
5841
+
5842
+ server.tool(
5843
+ "projects_list",
5844
+ "List requirement projects in your network (id, name, color, sort, archived).",
5845
+ { network_id: z.string().max(200).optional() },
5846
+ async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
5847
+ );
5677
5848
  }
5678
5849
 
5679
5850
  // ────────────────────────────────────────────────────────────────────