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

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.88",
3
+ "version": "0.9.0-preview.89",
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,163 @@
1
+ // #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误带上 field / message / hint,`error` 原样。
2
+ // 每条提示一个测试(经 MCP,以 Agent / 人的身份),外加一道取集门:requirements.ts 里返回 / 抛出的每个错误码都在
3
+ // requirements-errors.ts 的登记表里,新加的错误码没登记 ⇒ 红。
4
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
5
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
6
+ import { join } from "path";
7
+ import { tmpdir } from "os";
8
+ import { register, createNetworkTokenForNode } from "./auth.js";
9
+ import { db } from "./db.js";
10
+ import { __documentedErrorCodes, errorBody } from "./requirements-errors.js";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-errors-"));
13
+ let BASE = "";
14
+ let hub: any = null;
15
+ const PW = "ReqErrorsPassw0rd!xyz";
16
+ let NET = "", ownerToken = "", ownerId = "", nodeToken = "", memberToken = "", cardId = "";
17
+
18
+ async function rest(token: string, method: string, path: string, body?: unknown) {
19
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
20
+ const text = await res.text();
21
+ let json: any = null;
22
+ try { json = JSON.parse(text); } catch {}
23
+ return { status: res.status, body: json };
24
+ }
25
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
26
+ const res = await fetch(`${BASE}/mcp`, {
27
+ method: "POST",
28
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
29
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
30
+ });
31
+ const raw = await res.text();
32
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
33
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
34
+ return JSON.parse(out.result.content[0].text);
35
+ }
36
+
37
+ beforeAll(async () => {
38
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
39
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
40
+ process.env.HOST = "127.0.0.1";
41
+ const a = register(`reqerr_owner_${Date.now()}`, PW, undefined, "Owner");
42
+ ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
43
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [ownerId]);
44
+ nodeToken = createNetworkTokenForNode(ownerId, NET, "示例-甲", "node_reqerr_a").token!;
45
+ const mod: any = await import("./server.js");
46
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ BASE = `http://127.0.0.1:${hub.port}`;
48
+ const username = `reqerr_member_${Date.now()}`;
49
+ expect((await rest(ownerToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
50
+ memberToken = (await rest("", "POST", "/api/auth/login", { username, password: PW })).body.token;
51
+ cardId = (await mcp(nodeToken, "requirements_create", { name: "错误提示测试" })).requirement.id;
52
+ }, 30_000);
53
+
54
+ afterAll(() => {
55
+ try { hub?.stop?.(true); } catch {}
56
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
57
+ });
58
+
59
+ describe("each hint, over MCP", () => {
60
+ test("invalid_due: field due, the accepted formats", async () => {
61
+ for (const due of ["下周五", "2026/10/10"]) {
62
+ const r = await mcp(nodeToken, "requirements_update", { id: cardId, due });
63
+ expect(r.error).toBe("invalid_due");
64
+ expect(r.status).toBe(400);
65
+ expect(r.field).toBe("due");
66
+ expect(r.hint).toContain("YYYY-MM-DD");
67
+ expect(r.hint).toContain("ISO 8601");
68
+ }
69
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, start: "明天" })).field).toBe("start");
70
+ });
71
+
72
+ test("person_not_in_network: names the field, explains node_id vs user_id and where to find them", async () => {
73
+ const byAlias = await mcp(nodeToken, "requirements_update", { id: cardId, agent_owner: { kind: "node", id: "示例-甲" } });
74
+ expect(byAlias.error).toBe("person_not_in_network");
75
+ expect(byAlias.field).toBe("agent_owner");
76
+ expect(byAlias.hint).toContain("get_all_status");
77
+ expect(byAlias.hint).toContain("Aliases and usernames are not accepted");
78
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "user", id: "u_nobody" } })).field).toBe("owner");
79
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, participants: [{ kind: "node", id: "node_reqerr_a" }, { kind: "user", id: "u_nobody" }] })).field).toBe("participants");
80
+ });
81
+
82
+ test("project_not_in_network: a name is recognised as a name; both point to projects_list", async () => {
83
+ const byName = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "示例项目" });
84
+ expect(byName.error).toBe("project_not_in_network");
85
+ expect(byName.field).toBe("project_id");
86
+ expect(byName.message).toContain("looks like a project name");
87
+ expect(byName.hint).toContain("projects_list");
88
+ const byId = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "proj_missing" });
89
+ expect(byId.error).toBe("project_not_in_network");
90
+ expect(byId.message).not.toContain("project name");
91
+ expect(byId.hint).toContain("projects_list");
92
+ });
93
+
94
+ test("empty_patch: lists the writable fields; sending status says to use column", async () => {
95
+ // The MCP tool schema strips keys it does not know, so `status` arrives as nothing; REST sees the raw body.
96
+ const viaRest = await rest(nodeToken, "PATCH", `/api/requirements/${cardId}`, { status: "doing" });
97
+ expect(viaRest.status).toBe(400);
98
+ expect(viaRest.body.error).toBe("empty_patch");
99
+ expect(viaRest.body.hint).toContain("use column");
100
+ expect(viaRest.body.hint).toContain("Writable fields: column");
101
+ expect(viaRest.body.message).toContain("status");
102
+ const viaMcp = await mcp(nodeToken, "requirements_update", { id: cardId });
103
+ expect(viaMcp.error).toBe("empty_patch");
104
+ expect(viaMcp.hint).toContain("Writable fields:");
105
+ expect(viaMcp.hint).not.toContain("use column");
106
+ });
107
+
108
+ test("user_token_required: says projects are managed by people and what to do instead", async () => {
109
+ const r = await mcp(nodeToken, "projects_create", { name: "节点建项目" });
110
+ expect(r.error).toBe("user_token_required");
111
+ expect(r.status).toBe(403);
112
+ expect(r.message).toContain("node token");
113
+ expect(r.hint).toContain("projects_list");
114
+ });
115
+
116
+ test("network_id_required: field network_id and how to fix it", async () => {
117
+ const r = await mcp(memberToken, "projects_create", { name: "成员建项目" });
118
+ expect(r.error).toBe("network_id_required");
119
+ expect(r.field).toBe("network_id");
120
+ expect(r.hint).toContain("network_id");
121
+ });
122
+
123
+ test("swept codes carry hints too: checklist_item_not_found, external_ref_exists (existing_id kept), owner_must_be_human, parent_cycle", async () => {
124
+ const ck = await mcp(nodeToken, "requirements_checklist_toggle", { id: cardId, item_id: "nope", done: true });
125
+ expect(ck.error).toBe("checklist_item_not_found");
126
+ expect(ck.hint).toContain("requirements_get");
127
+ await mcp(nodeToken, "requirements_create", { name: "外部", external_ref: "github:example/repo#472" });
128
+ const dup = await mcp(nodeToken, "requirements_create", { name: "外部又一次", external_ref: "github:example/repo#472" });
129
+ expect(dup.error).toBe("external_ref_exists");
130
+ expect(typeof dup.existing_id).toBe("string");
131
+ expect(dup.hint).toContain("requirements_upsert_by_external_ref");
132
+ const own = await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "node", id: "node_reqerr_a" } });
133
+ expect(own.error).toBe("owner_must_be_human");
134
+ expect(own.hint).toContain("agent_owner");
135
+ const cyc = await mcp(nodeToken, "requirements_update", { id: cardId, parent_id: cardId });
136
+ expect(cyc.error).toBe("parent_cycle");
137
+ expect(cyc.field).toBe("parent_id");
138
+ });
139
+ });
140
+
141
+ describe("compatibility", () => {
142
+ test("REST keeps the exact error strings the app matches on; the new keys are additive", async () => {
143
+ const r = await rest(ownerToken, "PATCH", `/api/requirements/${cardId}`, { due: "下周五" });
144
+ expect(r.status).toBe(400);
145
+ expect(r.body.ok).toBe(false);
146
+ expect(r.body.error).toBe("invalid_due");
147
+ expect(Object.keys(r.body).sort()).toEqual(["error", "field", "hint", "message", "ok"]);
148
+ // A code without an entry is exactly the old shape.
149
+ expect(errorBody("some_future_code")).toEqual({ ok: false, error: "some_future_code" });
150
+ });
151
+
152
+ test("every error code requirements.ts returns or throws has an entry (a new code must be documented)", () => {
153
+ const src = readFileSync(join(import.meta.dir, "requirements.ts"), "utf8");
154
+ const codes = new Set<string>();
155
+ for (const m of src.matchAll(/jsonError\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
156
+ for (const m of src.matchAll(/(?:new (?:Error|RequirementFieldError))\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
157
+ for (const m of src.matchAll(/errorBody\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
158
+ for (const m of src.matchAll(/return\s+["'](parent_[a-z_]+)["']/g)) codes.add(m[1]);
159
+ expect(codes.size).toBeGreaterThan(40); // the scan really found the codes (not an empty pass)
160
+ const documented = new Set(__documentedErrorCodes());
161
+ expect([...codes].filter(c => !documented.has(c)).sort()).toEqual([]);
162
+ });
163
+ });
@@ -0,0 +1,129 @@
1
+ // #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误不再只有一个代码。
2
+ //
3
+ // 以前回 `{ ok:false, error:"invalid_due" }`:app 按代码自己出文案,能用;但 MCP 上的 Agent 拿到的就只有这个词,
4
+ // 不知道哪个字段错了、正确的格式是什么、下一步该调哪个工具。这里给每个代码配上:
5
+ // field —— 出错的字段(有的话),
6
+ // message —— 一句英文说明(MCP 工具描述都是英文,读的是 LLM;app 不显示它),
7
+ // hint —— 怎么改 / 下一步调什么。
8
+ // 🔴 `error` 字符串原样不变:app、测试、脚本都按它匹配。新增的三个键旧客户端不认识,直接忽略。
9
+ // 只有一个出口:requirements.ts 的 jsonError() 和抛错转换都走 errorBody(),不在调用点各写各的。
10
+
11
+ export type ErrorContext = {
12
+ /** 出错的字段(调用点知道时传;不传就用登记表里的默认字段)。 */
13
+ field?: string;
14
+ /** 请求里实际带了哪些键(empty_patch 用来指出「你传了 status,该用 column」)。 */
15
+ sent?: readonly string[];
16
+ /** 可写字段(empty_patch 列出来)。 */
17
+ writable?: readonly string[];
18
+ /** 出错的那个值(project_not_in_network 用来判断是不是传了项目名)。 */
19
+ value?: unknown;
20
+ };
21
+
22
+ type Detail = { field?: string; message: string; hint?: string };
23
+ type Entry = Detail | ((ctx: ErrorContext) => Detail);
24
+
25
+ const DUE_FORMATS = 'YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (e.g. 2026-10-10T18:00:00+08:00); "" clears it';
26
+ const PERSON_IDS = "node ids (kind:'node') come from get_all_status (node_id) or a task's agent_owner / participants; user ids (kind:'user') from a task's owner / created_by / participants. Aliases and usernames are not accepted.";
27
+
28
+ const ENTRIES: Record<string, Entry> = {
29
+ // ── 定位 / 范围 ──
30
+ network_id_required: { field: 'network_id', message: 'This token can see more than one network (or is not bound to one), so the network must be named.', hint: 'Pass network_id (the network the task or project belongs to).' },
31
+ ambiguous_seq: { field: 'id', message: 'That #N exists in more than one network you can see.', hint: 'Pass network_id, or use the req_… id.' },
32
+ requirement_not_found: { field: 'id', message: 'No task with that id or #N in this network (or you cannot see it).', hint: 'Find it with requirements_list (q= searches titles; include_archived=true also lists archived tasks).' },
33
+ not_found: { message: 'No such task, project or route.', hint: 'Check the id; requirements_list / projects_list return the ids you can use.' },
34
+ project_not_found: { field: 'id', message: 'No project with that id in this network.', hint: 'Call projects_list to get project ids (proj_…).' },
35
+ tag_not_found: { field: 'tag', message: 'No task in this network carries that tag.', hint: 'Tags are free text on tasks; list the ones in use from requirements_list (view=summary shows each task’s tags).' },
36
+ checklist_item_not_found: { field: 'item_id', message: 'This task has no checklist item with that id.', hint: 'Item ids (ck_…) are in requirements_get → checklist[].id.' },
37
+ // ── 权限 ──
38
+ user_token_required: { message: 'This operation is for people only: a node token can read projects and read / create / update tasks, but cannot manage projects or delete tasks.', hint: 'Use an existing project (projects_list), or ask a person to create / change the project in the app (任务 → 管理项目).' },
39
+ permission_denied: { message: 'Your role in this network cannot do this (read-only, or task-scoped to other projects).', hint: 'Ask a network admin for write access, or work on tasks you can see.' },
40
+ upsert_not_allowed: { message: 'Task-scoped members cannot upsert by external_ref.', hint: 'Use requirements_create / requirements_update on tasks you can see.' },
41
+ external_ref_not_allowed: { field: 'external_ref', message: 'Task-scoped members cannot set external_ref.', hint: 'Leave external_ref out.' },
42
+ // ── 人员 ──
43
+ invalid_person: (ctx) => ({ field: ctx.field ?? 'owner', message: 'A person must be an object {kind:"user"|"node", id}.', hint: PERSON_IDS }),
44
+ person_not_in_network: (ctx) => ({ field: ctx.field ?? 'owner', message: `${ctx.field ?? 'owner'} names nobody in this network (unknown id, wrong kind, or a node you cannot see).`, hint: PERSON_IDS }),
45
+ owner_must_be_human: { field: 'owner', message: 'owner (负责人) must be a person.', hint: "Use owner: {kind:'user', id}. To assign a node / Agent, use agent_owner: {kind:'node', id}." },
46
+ agent_owner_must_be_agent: { field: 'agent_owner', message: 'agent_owner (负责 Agent) must be a node.', hint: "Use agent_owner: {kind:'node', id}. To assign a person, use owner: {kind:'user', id}." },
47
+ agent_owner_not_granted: { field: 'agent_owner', message: 'The current responsible Agent is one you are not granted, so you cannot replace or clear it.', hint: 'Leave agent_owner out of the patch.' },
48
+ invalid_participants: { field: 'participants', message: 'participants must be an array of up to 100 persons {kind, id}; it replaces the whole list.', hint: 'Send the full list you want (read it first with requirements_get).' },
49
+ invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
50
+ // ── 项目 ──
51
+ invalid_project: { field: 'project_id', message: 'project_id must be a project id string or null.', hint: 'Call projects_list to get project ids (proj_…); null removes the task from its project.' },
52
+ project_not_in_network: (ctx) => {
53
+ const looksLikeName = typeof ctx.value === 'string' && !/^proj_/.test(ctx.value);
54
+ return {
55
+ field: ctx.field ?? 'project_id',
56
+ message: looksLikeName ? `"${String(ctx.value).slice(0, 40)}" is not a project id — it looks like a project name.` : 'No project with that id in this network.',
57
+ hint: 'Call projects_list and pass the project’s id (proj_…), not its name.',
58
+ };
59
+ },
60
+ project_archived: { field: 'project_id', message: 'That project is archived.', hint: 'Pick an active project from projects_list, or ask a person to unarchive it.' },
61
+ project_name_taken: { field: 'name', message: 'A project with that name already exists in this network.', hint: 'Use the existing one (projects_list) or choose another name.' },
62
+ invalid_project_name: { field: 'name', message: 'Project name must be 1–40 characters.' },
63
+ invalid_project_color: { field: 'color', message: 'color must be #RRGGBB.' },
64
+ invalid_project_sort: { field: 'sort', message: 'sort must be an integer.' },
65
+ invalid_project_archived: { field: 'archived', message: 'archived must be true or false.' },
66
+ too_many_projects: { message: 'This network has reached the project limit (200).', hint: 'Reuse an existing project (projects_list).' },
67
+ // ── 任务字段 ──
68
+ empty_patch: (ctx) => {
69
+ const sent = ctx.sent ?? [];
70
+ const writable = ctx.writable?.length ? ctx.writable.join(', ') : 'name, column, priority, due, start, description, checklist, tags, owner, agent_owner, participants, project_id, parent_id, external_ref, external_url, archived';
71
+ const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done, use column (status is the name of the list filter). ' : '';
72
+ return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
73
+ },
74
+ invalid_json: { message: 'The request body must be a JSON object.' },
75
+ invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
76
+ invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
77
+ invalid_column: { field: 'column', message: 'column must be pool, doing or done.' },
78
+ invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing or done.' },
79
+ invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
80
+ invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
81
+ invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
82
+ invalid_tags: { field: 'tags', message: 'tags must be an array of at most 10 strings, each 1–20 characters.', hint: 'Omit tags to keep them, [] to clear.' },
83
+ invalid_checklist: { field: 'checklist', message: 'checklist must be an array of up to 100 items {text (1–500 chars), done?, id?}; it replaces the whole list.', hint: 'To tick one item use requirements_checklist_toggle (item ids from requirements_get).' },
84
+ invalid_done: { field: 'done', message: 'done must be true or false.' },
85
+ invalid_parent_id: { field: 'parent_id', message: 'parent_id must be a task id in this network (or null to detach).', hint: 'Get the parent’s req_… id from requirements_list / requirements_get.' },
86
+ parent_cycle: { field: 'parent_id', message: 'That parent would make the task its own ancestor.', hint: 'Pick a parent that is not this task or one of its children.' },
87
+ parent_too_deep: { field: 'parent_id', message: 'Tasks nest at most 5 levels.', hint: 'Attach it to a higher-level task.' },
88
+ parent_not_found: { field: 'parent_id', message: 'No task with that parent_id in this network (or you cannot see it).', hint: 'Get the parent’s req_… id from requirements_list / requirements_get.' },
89
+ invalid_external_ref: { field: 'external_ref', message: 'external_ref must start with a letter or digit and use only letters, digits and _ . : / # @ + - (≤ 200 characters).', hint: 'e.g. github:owner/repo#123' },
90
+ external_ref_required: { field: 'external_ref', message: 'external_ref is required for upsert.' },
91
+ external_ref_exists: { field: 'external_ref', message: 'A task with that external_ref already exists in this network (existing_id).', hint: 'Use requirements_upsert_by_external_ref to sync, or requirements_update on existing_id.' },
92
+ invalid_external_url: { field: 'external_url', message: 'external_url must be an http(s) URL of at most 500 characters, or null.' },
93
+ invalid_issues: { field: 'issues', message: 'issues must be an array of issue references.' },
94
+ invalid_archived: { field: 'archived', message: 'archived must be true or false.' },
95
+ invalid_seq: { field: 'seq', message: 'seq must be a positive integer (the #N shown for a task).' },
96
+ invalid_client_id: { field: 'client_id', message: 'client_id must be 1–80 characters of letters, digits, ., _ or -.' },
97
+ client_id_taken: { field: 'client_id', message: 'That client_id was already used for another task.', hint: 'Generate a new client_id for each new task.' },
98
+ // ── 列表 / 事件 ──
99
+ invalid_view: { field: 'view', message: 'view must be full or summary.' },
100
+ invalid_limit: { field: 'limit', message: 'limit is out of range.' },
101
+ invalid_cursor: { field: 'cursor', message: 'cursor is not one this hub issued.', hint: 'Pass next_cursor from the previous page unchanged, or start again without cursor.' },
102
+ invalid_updated_since: { field: 'updated_since', message: 'updated_since must be an ISO 8601 time.', hint: 'Use server_time from the previous changes=true response.' },
103
+ updated_since_required: { field: 'updated_since', message: 'changes=true needs updated_since.', hint: 'Pass updated_since (ISO time; server_time from the last call).' },
104
+ invalid_since: { field: 'since', message: 'since must be an ISO 8601 time.', hint: 'Use server_time from the previous requirements_events response.' },
105
+ insert_failed: { message: 'The hub could not save the task.', hint: 'Retry once; if it fails again, report it.' },
106
+ };
107
+
108
+ /** 错误响应体:`error` 原样,补上 field / message / hint(登记表里没有的代码就只有 error,和以前一样)。 */
109
+ export function errorBody(error: string, ctx: ErrorContext = {}): { ok: false; error: string; field?: string; message?: string; hint?: string } {
110
+ const entry = ENTRIES[error];
111
+ if (!entry) return { ok: false, error };
112
+ const d = typeof entry === 'function' ? entry(ctx) : entry;
113
+ const field = ctx.field ?? d.field;
114
+ return { ok: false, error, ...(field ? { field } : {}), message: d.message, ...(d.hint ? { hint: d.hint } : {}) };
115
+ }
116
+
117
+ /** 带字段的抛错:assignments / projectRef 里抛,调用点的 catch 用 errorFrom() 转成响应。 */
118
+ export class RequirementFieldError extends Error {
119
+ constructor(code: string, readonly field?: string, readonly value?: unknown) { super(code); }
120
+ }
121
+
122
+ export function errorFrom(e: unknown, status = 400): Response {
123
+ const code = (e as Error)?.message ?? 'invalid_json';
124
+ const fe = e instanceof RequirementFieldError ? e : null;
125
+ return Response.json(errorBody(code, { field: fe?.field, value: fe?.value }), { status });
126
+ }
127
+
128
+ /** Test-only: every code with an entry. */
129
+ export const __documentedErrorCodes = (): string[] => Object.keys(ENTRIES);
@@ -0,0 +1,155 @@
1
+ // #471(#469 问题 3)—— MCP requirements_list 默认省流 + 严格参数。
2
+ //
3
+ // 钉住:
4
+ // 1. MCP 默认 = summary 视图 + 50 张一页:120 张有正文 / 检查项 / 标签 / 参与人的卡,第一页 50 张、has_more、
5
+ // next_cursor 接着翻,两页不重叠;一页的线上字节有上限(见 SUMMARY_PAGE_MAX_BYTES)。
6
+ // 2. view='full' 照旧给全文(description / checklist);limit 可显式放大。
7
+ // 3. 不认识的参数(以前的 tag 写错成 tags / 任何拼错)→ -32602,错误里列出能用的参数;不会回整张表。
8
+ // 4. tag= 精确筛(区分大小写),和 limit / cursor 一起翻页。
9
+ // 5. REST GET /api/requirements 不变:不带参数仍是 full + 最多 500 张(App 靠它)。
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/requirements-list-mcp-slim-http.test.ts
12
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { register } from "./auth.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
21
+ if (!activeDbPath) throw new Error("requirements-list-mcp-slim requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
22
+
23
+ const TASKS = 120;
24
+ /**
25
+ * 一页 summary(50 张;每张中文标题、3 个标签、负责人 + 参与人、8 条检查项、1.5 KB 正文)的字节上限。
26
+ * 这批数据实测 39.7 KB(≈ 795 B / 张,中文 3 字节 / 字);生产 122 张 summary 81 KB ≈ 670 B / 张 → 50 张约 33 KB。
27
+ * 上限 45 KB。同一批数据走旧默认(full、500 张)是整张表 120 张全文。
28
+ */
29
+ const SUMMARY_PAGE_MAX_BYTES = 45_000;
30
+
31
+ let server: any;
32
+ let base = "";
33
+ let token = "", userId = "", netId = "";
34
+ let call: (args: Record<string, unknown>) => Promise<{ isError: boolean; text: string }>;
35
+ let closeMcp: () => Promise<void>;
36
+
37
+ const DESCRIPTION = "背景:".padEnd(1500, "这是一段很长的任务描述,用来模拟真实看板上的正文。");
38
+ const checklist = Array.from({ length: 8 }, (_, i) => ({ text: `检查项 ${i + 1}:确认这一步已经完成并且有记录` }));
39
+
40
+ beforeAll(async () => {
41
+ const stamp = Date.now();
42
+ const u = register(`slim_${stamp}`, "SlimList123!", undefined, "seed");
43
+ expect(u.ok).toBe(true);
44
+ token = u.token!; userId = u.user!.user_id; netId = u.network_id!;
45
+ const mod: any = await import("./server.js");
46
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ base = `http://127.0.0.1:${server.port}`;
48
+
49
+ const s = new McpServer({ name: "slim", version: "1" });
50
+ registerTools(s, undefined, null, userId, u.user!.username, false, "tok_user");
51
+ const client = new Client({ name: "slim-client", version: "1" });
52
+ const [ct, st] = InMemoryTransport.createLinkedPair();
53
+ await s.connect(st);
54
+ await client.connect(ct);
55
+ const raw = async (name: string, args: Record<string, unknown>) => {
56
+ const r: any = await client.callTool({ name, arguments: args });
57
+ return { isError: r.isError === true, text: String(r.content?.[0]?.text ?? "") };
58
+ };
59
+ call = (args) => raw("requirements_list", { network_id: netId, ...args });
60
+ closeMcp = async () => { await client.close(); await s.close(); };
61
+
62
+ for (let i = 0; i < TASKS; i++) {
63
+ const tags = ["bug", "后端", `批次${i % 4}`];
64
+ if (i % 3 === 0) tags[0] = "Bug"; // 大小写不同的另一个标签
65
+ const r = await raw("requirements_create", {
66
+ network_id: netId, name: `任务 ${i}:修复列表接口返回过大的问题`, description: DESCRIPTION, checklist, tags,
67
+ owner: { kind: "user", id: userId }, participants: [{ kind: "user", id: userId }],
68
+ priority: "normal", column: i % 2 ? "doing" : "pool",
69
+ });
70
+ expect(r.isError).toBe(false);
71
+ }
72
+ }, 120_000);
73
+
74
+ afterAll(async () => { await closeMcp?.(); server?.stop?.(true); });
75
+
76
+ describe("#471 MCP requirements_list defaults", () => {
77
+ test("default = summary, 50 per page, under the byte bound; cursor pages without overlap", async () => {
78
+ const first = await call({});
79
+ expect(first.isError).toBe(false);
80
+ const bytes = Buffer.byteLength(first.text, "utf8");
81
+ const body = JSON.parse(first.text);
82
+ expect(body.view).toBe("summary");
83
+ expect(body.requirements.length).toBe(50);
84
+ expect(body.has_more).toBe(true);
85
+ expect(typeof body.next_cursor).toBe("string");
86
+ for (const r of body.requirements) {
87
+ expect(r.description).toBeUndefined();
88
+ expect(r.checklist).toBeUndefined();
89
+ expect(r.has_description).toBe(true);
90
+ expect(r.checklist_count).toEqual({ total: 8, done: 0 });
91
+ }
92
+ console.log(`summary page of 50: ${bytes} B`);
93
+ expect(bytes).toBeLessThan(SUMMARY_PAGE_MAX_BYTES);
94
+
95
+ const second = JSON.parse((await call({ cursor: body.next_cursor })).text);
96
+ expect(second.requirements.length).toBe(50);
97
+ const third = JSON.parse((await call({ cursor: second.next_cursor })).text);
98
+ expect(third.requirements.length).toBe(TASKS - 100);
99
+ expect(third.has_more).toBe(false);
100
+ const ids = [...body.requirements, ...second.requirements, ...third.requirements].map((r: any) => r.id);
101
+ expect(new Set(ids).size).toBe(TASKS);
102
+ });
103
+
104
+ test("view='full' still returns description and checklist; limit is honoured", async () => {
105
+ const r = JSON.parse((await call({ view: "full", limit: 3 })).text);
106
+ expect(r.view).toBeUndefined();
107
+ expect(r.requirements.length).toBe(3);
108
+ expect(r.requirements[0].description).toBe(DESCRIPTION);
109
+ expect(r.requirements[0].checklist.length).toBe(8);
110
+ });
111
+
112
+ test("unknown parameters → -32602 listing the valid ones; never the whole table", async () => {
113
+ for (const bad of [{ tags: "bug" }, { lable: "x" }, { status: "doing", foo: 1 }]) {
114
+ const r = await call(bad);
115
+ expect(r.isError).toBe(true);
116
+ expect(r.text).toContain("-32602");
117
+ expect(r.text).toContain(`unknown parameter(s): ${Object.keys(bad).filter(k => k !== "status").join(", ")}`);
118
+ expect(r.text).toContain("valid parameters: network_id, seq, status, project_id, owner, agent_owner, tag,");
119
+ expect(r.text).not.toContain('"requirements":[');
120
+ }
121
+ });
122
+
123
+ test("tag = exact label match (case-sensitive), pages with limit/cursor", async () => {
124
+ const lower = JSON.parse((await call({ tag: "bug", limit: 100 })).text);
125
+ expect(lower.requirements.length).toBe(TASKS - TASKS / 3);
126
+ expect(lower.requirements.every((r: any) => r.tags.includes("bug"))).toBe(true);
127
+ const upper = JSON.parse((await call({ tag: "Bug", limit: 100 })).text);
128
+ expect(upper.requirements.length).toBe(TASKS / 3);
129
+ expect(upper.requirements.every((r: any) => r.tags.includes("Bug") && !r.tags.includes("bug"))).toBe(true);
130
+ const p1 = JSON.parse((await call({ tag: "批次1", limit: 20 })).text);
131
+ expect(p1.requirements.length).toBe(20);
132
+ expect(p1.has_more).toBe(true);
133
+ const p2 = JSON.parse((await call({ tag: "批次1", limit: 20, cursor: p1.next_cursor })).text);
134
+ expect(p2.requirements.length).toBe(TASKS / 4 - 20);
135
+ expect(p2.has_more).toBe(false);
136
+ expect((await call({ tag: "nope" })).text).toContain('"requirements":[]');
137
+ // tag + q 组合:两个条件都要满足
138
+ const both = JSON.parse((await call({ tag: "批次2", q: "任务 10", limit: 100 })).text);
139
+ expect(both.requirements.map((r: any) => r.name)).toContain("任务 10:修复列表接口返回过大的问题");
140
+ expect(both.requirements.every((r: any) => r.tags.includes("批次2") && /任务 \d*10/.test(r.name))).toBe(true);
141
+ });
142
+
143
+ test("REST GET /api/requirements is unchanged: no params = full view, up to 500", async () => {
144
+ const res = await fetch(`${base}/api/requirements?network_id=${netId}`, { headers: { Authorization: `Bearer ${token}` } });
145
+ expect(res.status).toBe(200);
146
+ const body = await res.json() as any;
147
+ expect(body.view).toBeUndefined();
148
+ expect(body.requirements.length).toBe(TASKS);
149
+ expect(body.requirements[0].description).toBe(DESCRIPTION);
150
+ // REST 的 tag= 是新增的可选参数:不合法 → 400,与其它筛选参数同一形状
151
+ const bad = await fetch(`${base}/api/requirements?network_id=${netId}&tag=${"x".repeat(21)}`, { headers: { Authorization: `Bearer ${token}` } });
152
+ expect(bad.status).toBe(400);
153
+ expect(((await bad.json()) as any).error).toBe("invalid_tag");
154
+ });
155
+ });
@@ -0,0 +1,145 @@
1
+ // #470(MCP 任务生命周期测试报告问题 1)—— Agent 经 MCP 把负责人设成节点:以前走旧 App 的兼容改写,
2
+ // 人类负责人被静默清空、负责 Agent 被换掉,响应还显示 owner=节点。现在:
3
+ // - MCP 上 owner 传节点(建 / 改 / upsert,节点令牌和用户令牌都一样)→ 400 owner_must_be_human + hint,什么都不写;
4
+ // - MCP 写入的响应就是落库的那一行(和紧接着的 requirements_get 一字不差);
5
+ // - REST 上旧 App ≤ 0.2.142 的兼容路径不变(requirements-http.test.ts「old-app compat」那条钉着)。
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register, createNetworkTokenForNode } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-owner-strict-mcp-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+ const PW = "OwnerStrictMcpPassw0rd!x";
17
+ let NET = "", ownerToken = "", ownerId = "", nodeToken = "";
18
+ const human = () => ({ kind: "user", id: ownerId });
19
+ const nodeA = { kind: "node", id: "node_strict_a" };
20
+ const nodeB = { kind: "node", id: "node_strict_b" };
21
+
22
+ async function rest(token: string, method: string, path: string, body?: unknown) {
23
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
24
+ const text = await res.text();
25
+ let json: any = null;
26
+ try { json = JSON.parse(text); } catch {}
27
+ return { status: res.status, body: json };
28
+ }
29
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
30
+ const res = await fetch(`${BASE}/mcp`, {
31
+ method: "POST",
32
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
33
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
34
+ });
35
+ const raw = await res.text();
36
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
37
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
38
+ return JSON.parse(out.result.content[0].text);
39
+ }
40
+ const stored = (id: string) => db.get<{ owner_json: string | null; agent_owner_json: string | null }>("SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id = ?1", id)!;
41
+
42
+ beforeAll(async () => {
43
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
44
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
45
+ process.env.HOST = "127.0.0.1";
46
+ const a = register(`strict_owner_${Date.now()}`, PW, undefined, "Owner");
47
+ ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
48
+ nodeToken = createNetworkTokenForNode(ownerId, NET, "strict-a", "node_strict_a").token!;
49
+ createNetworkTokenForNode(ownerId, NET, "strict-b", "node_strict_b");
50
+ const mod: any = await import("./server.js");
51
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
52
+ BASE = `http://127.0.0.1:${hub.port}`;
53
+ }, 30_000);
54
+
55
+ afterAll(() => {
56
+ try { hub?.stop?.(true); } catch {}
57
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
58
+ });
59
+
60
+ describe("MCP: owner must be a person", () => {
61
+ test("update owner=node → 400 owner_must_be_human + hint; the human owner and the agent owner are untouched", async () => {
62
+ const card = (await mcp(nodeToken, "requirements_create", { name: "负责人保护", owner: human(), agent_owner: nodeB })).requirement;
63
+ expect(card.owner).toEqual(human());
64
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, owner: nodeA });
65
+ expect(r.ok).toBe(false);
66
+ expect(r.error).toBe("owner_must_be_human");
67
+ expect(r.status).toBe(400);
68
+ expect(r.hint).toContain("agent_owner");
69
+ expect(r.owner_coerced_to_agent_owner).toBeUndefined();
70
+ const row = stored(card.id);
71
+ expect(JSON.parse(row.owner_json!)).toEqual(human());
72
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeB);
73
+ const got = (await mcp(nodeToken, "requirements_get", { id: card.id })).requirement;
74
+ expect(got.owner).toEqual(human());
75
+ expect(got.agent_owner).toEqual(nodeB);
76
+ });
77
+
78
+ test("…the same for a user token over MCP, for create (nothing is written) and for upsert", async () => {
79
+ const r = await mcp(ownerToken, "requirements_update", { network_id: NET, id: (await mcp(ownerToken, "requirements_create", { network_id: NET, name: "用户令牌", owner: human() })).requirement.id, owner: nodeA });
80
+ expect(r.error).toBe("owner_must_be_human");
81
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n;
82
+ const c = await mcp(nodeToken, "requirements_create", { name: "建时就传节点", owner: nodeA });
83
+ expect(c.error).toBe("owner_must_be_human");
84
+ expect(c.hint).toContain("agent_owner");
85
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n).toBe(before);
86
+ const u = await mcp(nodeToken, "requirements_upsert_by_external_ref", { external_ref: "github:example/repo#470", name: "upsert", owner: nodeA });
87
+ expect(u.error).toBe("owner_must_be_human");
88
+ expect(db.get("SELECT 1 FROM requirements WHERE external_ref = 'github:example/repo#470'")).toBeNull();
89
+ });
90
+
91
+ test("the right way still works: agent_owner = node, owner = person, owner = null", async () => {
92
+ const card = (await mcp(nodeToken, "requirements_create", { name: "正确的写法", owner: human() })).requirement;
93
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: nodeA });
94
+ expect(r.ok).toBe(true);
95
+ expect(r.requirement.owner).toEqual(human());
96
+ expect(r.requirement.agent_owner).toEqual(nodeA);
97
+ const cleared = await mcp(nodeToken, "requirements_update", { id: card.id, owner: null });
98
+ expect(cleared.requirement.owner).toBeNull();
99
+ // agent_owner given a person is refused the other way round, with its own hint.
100
+ const wrong = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: human() });
101
+ expect(wrong.error).toBe("agent_owner_must_be_agent");
102
+ expect(wrong.hint).toContain("owner");
103
+ });
104
+
105
+ test("every MCP write's response equals a fresh requirements_get", async () => {
106
+ const card = (await mcp(nodeToken, "requirements_create", { name: "响应即落库", owner: human(), checklist: [{ text: "一" }] })).requirement;
107
+ expect((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement).toEqual(card);
108
+ const patches: Record<string, unknown>[] = [
109
+ { column: "doing" }, { agent_owner: nodeA }, { participants: [nodeB, human()] }, { owner: null }, { owner: human(), priority: "high" },
110
+ { agent_owner: null }, { due: "2026-10-10" }, { archived: true }, { archived: false },
111
+ ];
112
+ for (const p of patches) {
113
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, ...p });
114
+ expect(r.ok).toBe(true);
115
+ expect(r.owner_coerced_to_agent_owner).toBeUndefined();
116
+ expect(r.requirement).toEqual((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement);
117
+ }
118
+ });
119
+ });
120
+
121
+ describe("REST", () => {
122
+ test("new clients' responses equal a fresh GET (the old-app compat echo is the only exception, pinned in requirements-http.test.ts)", async () => {
123
+ const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "REST 响应", owner: human() })).body.requirement;
124
+ for (const p of [{ agent_owner: nodeA }, { owner: human(), agent_owner: nodeB }, { participants: [nodeA] }]) {
125
+ const r = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, p);
126
+ expect(r.status).toBe(200);
127
+ expect(r.body.requirement).toEqual((await rest(ownerToken, "GET", `/api/requirements/${card.id}`)).body.requirement);
128
+ }
129
+ // A new client (sends agent_owner) passing a node as owner: still strict, now with the hint.
130
+ const strict = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA, agent_owner: nodeB });
131
+ expect(strict.status).toBe(400);
132
+ expect(strict.body.error).toBe("owner_must_be_human");
133
+ expect(strict.body.hint).toContain("agent_owner");
134
+ });
135
+
136
+ test("old-app compat on REST is unchanged: owner=node without agent_owner is still coerced (200 + flag)", async () => {
137
+ const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "旧 App", owner: human() })).body.requirement;
138
+ const old = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA });
139
+ expect(old.status).toBe(200);
140
+ expect(old.body.owner_coerced_to_agent_owner).toBe(true);
141
+ const row = stored(card.id);
142
+ expect(row.owner_json).toBeNull();
143
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeA);
144
+ });
145
+ });
@@ -94,7 +94,7 @@ export function decodeCursor(raw: string): Cursor | null {
94
94
  }
95
95
  }
96
96
 
97
- export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string };
97
+ export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string; /** #471 —— 只要带这个标签的卡(精确、区分大小写);null = 不按标签筛。 */ tag: string | null };
98
98
 
99
99
  /**
100
100
  * 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
@@ -134,5 +134,12 @@ export function parseListQuery(params: URLSearchParams): ListQuery | { error: st
134
134
  const q = params.get("q") ?? "";
135
135
  if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
136
136
  const terms = searchTerms(q);
137
- return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "" };
137
+ // #471 —— tag=:和卡片标签同一套规范(去首尾空白、1–20 个字符、不含控制字符);不带 = 旧行为。
138
+ const rawTag = params.get("tag");
139
+ let tag: string | null = null;
140
+ if (rawTag !== null) {
141
+ tag = rawTag.trim();
142
+ if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return { error: "invalid_tag" };
143
+ }
144
+ return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "", tag };
138
145
  }
@@ -1,5 +1,6 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
+ import { errorBody, errorFrom, RequirementFieldError, type ErrorContext } from "./requirements-errors.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
5
6
  import { db, logAudit } from "./db.js";
@@ -37,6 +38,12 @@ export type RequirementsRequestContext = {
37
38
  isAdmin: boolean;
38
39
  isNodeToken: boolean;
39
40
  scope: RestNetworkScope;
41
+ /**
42
+ * #470 —— 负责人(owner)只收人,传节点一律 400 owner_must_be_human,不走旧 App 的「节点负责人 → 负责 Agent」兼容。
43
+ * MCP 工具(Agent 与人经 MCP 的调用)总是严格:那条兼容只为 App ≤ 0.2.142 的 REST 保存存在,Agent 走它会
44
+ * 静默清空人类负责人(task-lifecycle 测试报告问题 1)。REST 上带 agent_owner 的新客户端也严格(见 assignments)。
45
+ */
46
+ strictOwner?: boolean;
40
47
  };
41
48
 
42
49
  const COLUMNS = new Set(["pool", "doing", "done"]);
@@ -78,8 +85,9 @@ type Row = {
78
85
  completed_at_approx: number | null;
79
86
  };
80
87
 
81
- function jsonError(error: string, status: number): Response {
82
- return Response.json({ ok: false, error }, { status });
88
+ // #472:错误码原样,统一补 field / message / hint(requirements-errors.ts,唯一出口)。
89
+ function jsonError(error: string, status: number, ctx?: ErrorContext): Response {
90
+ return Response.json(errorBody(error, ctx), { status });
83
91
  }
84
92
 
85
93
  // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
@@ -261,10 +269,10 @@ function projectNameTaken(networkId: string, name: string, except?: string): boo
261
269
  /** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
262
270
  function projectRef(value: unknown, networkId: string): string | null {
263
271
  if (value === null) return null;
264
- if (typeof value !== "string" || !value) throw new Error("invalid_project");
272
+ if (typeof value !== "string" || !value) throw new RequirementFieldError("invalid_project", "project_id", value);
265
273
  const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
266
- if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
267
- if (row.archived) throw new Error("project_archived");
274
+ if (!row || row.network_id !== networkId) throw new RequirementFieldError("project_not_in_network", "project_id", value);
275
+ if (row.archived) throw new RequirementFieldError("project_archived", "project_id", value);
268
276
  return row.project_id;
269
277
  }
270
278
 
@@ -512,15 +520,15 @@ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on
512
520
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
513
521
 
514
522
  type PersonRef = { kind: 'user' | 'node'; id: string };
515
- function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
516
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
523
+ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null, field = 'owner'): PersonRef {
524
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RequirementFieldError('invalid_person', field);
517
525
  const row = value as Record<string, unknown>;
518
- if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
526
+ if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new RequirementFieldError('invalid_person', field);
519
527
  const exists = row.kind === 'user'
520
528
  ? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
521
529
  : db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
522
530
  // 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
523
- if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
531
+ if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new RequirementFieldError('person_not_in_network', field);
524
532
  return { kind: row.kind, id: row.id };
525
533
  }
526
534
 
@@ -529,7 +537,7 @@ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null)
529
537
  // 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
530
538
  // → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
531
539
  // 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
532
- function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
540
+ function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null, strictOwner = false) {
533
541
  let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
534
542
  let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
535
543
  let coerced: PersonRef | null = null;
@@ -538,7 +546,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
538
546
  if ('owner' in body) {
539
547
  owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
540
548
  if (owner && owner.kind !== 'user') {
541
- if ('agent_owner' in body) throw new Error('owner_must_be_human');
549
+ if (strictOwner || 'agent_owner' in body) throw new Error('owner_must_be_human');
542
550
  coerced = owner;
543
551
  agentOwner = owner;
544
552
  owner = null;
@@ -547,13 +555,13 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
547
555
  if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
548
556
  if ('agent_owner' in body) {
549
557
  if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
550
- agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
558
+ agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden, 'agent_owner');
551
559
  if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
552
560
  }
553
561
  let participants = current ? JSON.parse(current.participants_json || '[]') : [];
554
562
  if ('participants' in body) {
555
563
  if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
556
- const refs = body.participants.map(value => personRef(value, networkId, hidden));
564
+ const refs = body.participants.map(value => personRef(value, networkId, hidden, 'participants'));
557
565
  // 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
558
566
  const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
559
567
  participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
@@ -741,7 +749,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
741
749
  const scoped = !!caller && isTaskScoped(caller.userId, networkId);
742
750
  let projectId: string | null = null;
743
751
  if (body.project_id !== undefined) {
744
- try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
752
+ try { projectId = projectRef(body.project_id, networkId); } catch (e) { return errorFrom(e); }
745
753
  // scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
746
754
  if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
747
755
  }
@@ -760,7 +768,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
760
768
  if (ref) {
761
769
  const existing = rowByExternalRef(networkId, ref);
762
770
  // 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
763
- if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
771
+ if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
764
772
  }
765
773
  if (clientId) {
766
774
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
@@ -770,7 +778,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
770
778
  const id = `req_${crypto.randomUUID()}`;
771
779
  const createdAt = new Date().toISOString();
772
780
  let people;
773
- try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
781
+ try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
774
782
  const actor = JSON.stringify(actorOf(ctx));
775
783
  try {
776
784
  // 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
@@ -790,7 +798,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
790
798
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
791
799
  if (ref) {
792
800
  const existing = rowByExternalRef(networkId, ref);
793
- if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
801
+ if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
794
802
  }
795
803
  if (!clientId) return jsonError("insert_failed", 500);
796
804
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
@@ -805,7 +813,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
805
813
  // ── 修改(省略的字段保留原值) ──
806
814
  const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id", "start"];
807
815
  function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
808
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
816
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
809
817
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
810
818
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
811
819
  const caller = taskCaller(ctx);
@@ -856,10 +864,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
856
864
  assignee = body.assignee.trim().slice(0, 80);
857
865
  }
858
866
  let people;
859
- try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
867
+ try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
860
868
  let projectId = row.project_id;
861
869
  if (has("project_id")) {
862
- try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
870
+ try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
863
871
  // scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
864
872
  if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
865
873
  }
@@ -869,7 +877,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
869
877
  if (next === undefined) return jsonError("invalid_external_ref", 400);
870
878
  if (next && next !== row.external_ref) {
871
879
  const taken = rowByExternalRef(row.network_id, next);
872
- if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
880
+ if (taken) return Response.json({ ...errorBody("external_ref_exists"), existing_id: taken.requirement_id }, { status: 409 });
873
881
  }
874
882
  ref = next;
875
883
  }
@@ -950,7 +958,7 @@ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response
950
958
  const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
951
959
  if (rows.length > 1) {
952
960
  const networks = rows.map(r => r.network_id);
953
- return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
961
+ return Response.json({ ...errorBody("ambiguous_seq"), networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
954
962
  }
955
963
  return rows[0] ?? jsonError("requirement_not_found", 404);
956
964
  }
@@ -1014,7 +1022,7 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
1014
1022
  where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
1015
1023
  }
1016
1024
  let rows: Row[];
1017
- if (!lq.terms.length) {
1025
+ if (!lq.terms.length && lq.tag === null) {
1018
1026
  rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
1019
1027
  } else {
1020
1028
  // seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
@@ -1025,7 +1033,9 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
1025
1033
  let m = maps.get(r.network_id);
1026
1034
  if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
1027
1035
  const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
1028
- if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
1036
+ // #471 —— tag= 在这里按存好的标签数组精确比(JSON 列,SQLite / PostgreSQL 写法不同,所以不进 SQL)。
1037
+ if (lq.tag !== null && !row.tags.includes(lq.tag)) continue;
1038
+ if (!lq.terms.length || matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
1029
1039
  if (hits.length > lq.limit) break;
1030
1040
  }
1031
1041
  const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
@@ -1436,7 +1446,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1436
1446
  if (req.method !== "PATCH") return jsonError("not_found", 404);
1437
1447
  let body: Record<string, unknown>;
1438
1448
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
1439
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
1449
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
1440
1450
  const row = scopedRow(ctx, id);
1441
1451
  if (row instanceof Response) return row;
1442
1452
  return patchRequirement(ctx, row, body);
package/src/tools.ts CHANGED
@@ -5833,6 +5833,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5833
5833
  isAdmin: false,
5834
5834
  isNodeToken: callerTokenIsNetwork,
5835
5835
  scope,
5836
+ // #470:经 MCP 的写一律严格 —— owner 传节点回 400 owner_must_be_human(+ hint),不走旧 App 的兼容改写。
5837
+ strictOwner: true,
5836
5838
  });
5837
5839
  const data = res ? await res.json() : { ok: false, error: "not_found" };
5838
5840
  return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
@@ -5849,7 +5851,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5849
5851
  description: z.string().max(20_000).optional().describe("markdown"),
5850
5852
  checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
5851
5853
  project_id: z.string().max(200).nullable().optional(),
5852
- owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
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"),
5853
5855
  agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
5854
5856
  participants: z.array(reqPerson).max(100).optional(),
5855
5857
  external_url: z.string().max(500).nullable().optional(),
@@ -5859,31 +5861,49 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5859
5861
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5860
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)";
5861
5863
 
5862
- server.tool(
5864
+ // #471 —— MCP 的列表默认省流:summary 视图 + 50 张一页(REST 的默认 full + 500 不变,App 靠它)。
5865
+ // 生产实测 122 张卡:旧默认(full、500)148 KB,summary 81 KB —— 一次调用就塞满 Agent 的上下文。
5866
+ // 输入 schema 严格:写错 / 不认识的参数(比如以前的 tag)原来会被静默丢掉、回整张表;现在回 -32602 并列出能用的参数。
5867
+ const REQ_LIST_MCP_DEFAULT_LIMIT = 50;
5868
+ const reqListShape = {
5869
+ network_id: z.string().max(200).optional(),
5870
+ seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
5871
+ status: z.enum(["pool", "doing", "done"]).optional(),
5872
+ project_id: z.string().max(200).optional(),
5873
+ owner: z.string().max(210).optional(),
5874
+ agent_owner: z.string().max(210).optional(),
5875
+ tag: z.string().min(1).max(80).optional().describe("only tasks carrying this label (exact, case-sensitive)"),
5876
+ updated_since: z.string().max(40).optional(),
5877
+ external_ref: z.string().max(200).optional(),
5878
+ include_archived: z.boolean().optional(),
5879
+ parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5880
+ top_level: z.boolean().optional(), q: z.string().max(200).optional(),
5881
+ limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
5882
+ cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
5883
+ view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
5884
+ changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
5885
+ };
5886
+ const reqListKeys = Object.keys(reqListShape);
5887
+ server.registerTool(
5863
5888
  "requirements_list",
5864
- "List requirement tasks in your network. 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'), 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). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}. view='summary' drops description and checklist bodies (adds has_description, checklist_count) — much smaller; use requirements_get for one task's full text. 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.",
5865
5889
  {
5866
- network_id: z.string().max(200).optional(),
5867
- seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
5868
- status: z.enum(["pool", "doing", "done"]).optional(),
5869
- project_id: z.string().max(200).optional(),
5870
- owner: z.string().max(210).optional(),
5871
- agent_owner: z.string().max(210).optional(),
5872
- updated_since: z.string().max(40).optional(),
5873
- external_ref: z.string().max(200).optional(),
5874
- include_archived: z.boolean().optional(),
5875
- parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5876
- top_level: z.boolean().optional(), q: z.string().max(200).optional(), limit: z.number().int().min(1).max(1000).optional(), cursor: z.string().max(600).optional(),
5877
- view: z.enum(["full", "summary"]).optional().describe("summary = no description / checklist bodies (has_description, checklist_count instead)"),
5878
- changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
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.`,
5891
+ // Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
5892
+ inputSchema: z.strictObject(reqListShape, {
5893
+ error: (iss: any) => iss.code === "unrecognized_keys"
5894
+ ? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqListKeys.join(", ")}`
5895
+ : undefined,
5896
+ }) as any,
5879
5897
  },
5880
- async (args) => {
5898
+ async (args: any) => {
5881
5899
  const q = new URLSearchParams();
5882
- for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
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]));
5901
+ if (!q.has("view")) q.set("view", "summary");
5902
+ if (!q.has("limit")) q.set("limit", String(REQ_LIST_MCP_DEFAULT_LIMIT));
5883
5903
  if (args.include_archived) q.set("include_archived", "1");
5884
5904
  if (args.top_level) q.set("top_level", "1");
5885
5905
  if (args.changes) q.set("changes", "1");
5886
- return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
5906
+ return requirementsCall("GET", `/api/requirements?${q}`, args.network_id);
5887
5907
  },
5888
5908
  );
5889
5909