@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.ts +40 -6
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments.ts +7 -1
- package/src/mcp-context-cost-http.test.ts +117 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-permissions-http.test.ts +258 -0
- package/src/node-permissions.ts +242 -0
- package/src/observer-avatar-http.test.ts +7 -5
- package/src/requirement-notify.ts +31 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements.ts +121 -8
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +229 -43
- package/src/task-access.ts +108 -12
- package/src/tools.ts +107 -34
package/src/task-access.ts
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
// 能改:负责人是我 / 我建的 / 项目授权 can_edit;能删:负责人是我 / 我建的;不能管项目。
|
|
8
8
|
// viewer 只看 ④,什么都不能改。
|
|
9
9
|
// owner/admin 角色与 Hub 管理员不看这一列。节点令牌(Agent)不经过这里 —— 规则原样不动。
|
|
10
|
+
// RFC-040(#455)再加两个来源,和上面的取并集、只加不减:部门项目授权(授权给我的部门或它的上级 ⇒ 同按人授权),
|
|
11
|
+
// 负责人权力(我负责的部门子树里的卡:可看 / 可改 / 可删;viewer 当负责人不算)。见 department-heads.ts。
|
|
10
12
|
//
|
|
11
13
|
// 判定是 fail-closed 的:task_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
|
|
12
14
|
|
|
13
15
|
import { db } from "./db.js";
|
|
16
|
+
import { deleteDepartmentGrantsForProject, departmentGrantsForMember, headCardsFor, isDepartmentCard, type DepartmentCards } from "./department-heads.js";
|
|
14
17
|
|
|
15
18
|
// ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
|
|
16
19
|
// ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
|
|
@@ -96,7 +99,7 @@ export function listProjectGrants(networkId: string, userId: string): ProjectGra
|
|
|
96
99
|
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
97
100
|
export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
|
|
98
101
|
|
|
99
|
-
type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
|
|
102
|
+
type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null; parent_id?: string | null };
|
|
100
103
|
const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
|
|
101
104
|
const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
|
|
102
105
|
const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
|
|
@@ -111,26 +114,85 @@ function projectGrant(networkId: string, userId: string, projectId: string | nul
|
|
|
111
114
|
return row ? { project_id: projectId, can_edit: row.can_edit === 1 } : null;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
/** 部门项目授权(RFC-040):null = 部门没授权这个项目;否则 = can_edit。 */
|
|
118
|
+
function departmentGrant(networkId: string, userId: string, projectId: string | null): boolean | null {
|
|
119
|
+
if (!projectId) return null;
|
|
120
|
+
const grants = departmentGrantsForMember(networkId, userId);
|
|
121
|
+
return grants.has(projectId) ? grants.get(projectId)! : null;
|
|
122
|
+
}
|
|
123
|
+
/** 这张卡是不是我负责的部门(含下级)的卡。 */
|
|
124
|
+
function headOf(userId: string, row: CardRow): boolean {
|
|
125
|
+
const cards = headCardsFor(row.network_id, userId);
|
|
126
|
+
return !!cards && isDepartmentCard(cards, row);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 调用者。userId = 按谁的任务权限判;node(RFC-041,#487)= 节点令牌按**主人**的权限判时带上节点:
|
|
131
|
+
* only=false(正常模式、执行开关打开)—— 主人能改的能改,另外「派给这个节点的」在主人看得见时也能改;
|
|
132
|
+
* only=true (受限模式)—— 只看、只改派给这个节点的卡(负责 Agent / 参与人 / 它建的,及这些卡的子任务)。
|
|
133
|
+
*/
|
|
134
|
+
export type TaskCaller = { userId: string; node?: { id: string; only: boolean } } | null;
|
|
135
|
+
|
|
136
|
+
export const nodeRefPattern = (nodeId: string) => `%${likeEscape(`"kind":"node","id":"${nodeId}"`)}%`;
|
|
137
|
+
const nodeRefIs = (json: string | null | undefined, nodeId: string) => !!json && json.includes(`"kind":"node","id":"${nodeId}"`);
|
|
138
|
+
const directlyAssigned = (row: Pick<CardRow, "agent_owner_json" | "participants_json" | "created_by_json">, nodeId: string) =>
|
|
139
|
+
nodeRefIs(row.agent_owner_json, nodeId) || nodeRefIs(row.participants_json, nodeId) || nodeRefIs(row.created_by_json, nodeId);
|
|
140
|
+
|
|
141
|
+
/** 这张卡派给了这个节点吗:负责 Agent / 参与人有它 / 它建的,或者父卡是。 */
|
|
142
|
+
export function cardAssignedToNode(row: CardRow, nodeId: string): boolean {
|
|
143
|
+
if (directlyAssigned(row, nodeId)) return true;
|
|
144
|
+
if (!row.parent_id) return false;
|
|
145
|
+
const parent = db.get<{ agent_owner_json: string | null; participants_json: string | null; created_by_json: string | null }>(
|
|
146
|
+
"SELECT agent_owner_json, participants_json, created_by_json FROM requirements WHERE requirement_id = ?1", row.parent_id);
|
|
147
|
+
return !!parent && directlyAssigned(parent, nodeId);
|
|
148
|
+
}
|
|
115
149
|
|
|
116
150
|
/** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
|
|
117
151
|
export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
|
|
152
|
+
if (caller?.node?.only && !cardAssignedToNode(row, caller.node.id)) return false;
|
|
153
|
+
return canSeeTaskAsUser(caller, row);
|
|
154
|
+
}
|
|
155
|
+
function canSeeTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
|
|
118
156
|
if (!caller) return true;
|
|
119
157
|
const m = memberRow(caller.userId, row.network_id);
|
|
120
158
|
if (!scopedRow(m)) return true;
|
|
121
159
|
if (projectGrant(row.network_id, caller.userId, row.project_id)) return true;
|
|
160
|
+
if (departmentGrant(row.network_id, caller.userId, row.project_id) !== null) return true;
|
|
122
161
|
if (m?.member_role === "viewer") return false;
|
|
123
|
-
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId);
|
|
162
|
+
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId) || headOf(caller.userId, row);
|
|
124
163
|
}
|
|
125
164
|
|
|
126
165
|
/** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
|
|
127
166
|
export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
167
|
+
if (caller?.node) {
|
|
168
|
+
const assigned = cardAssignedToNode(row, caller.node.id) && canSeeTaskAsUser(caller, row);
|
|
169
|
+
return caller.node.only ? assigned : assigned || canEditTaskAsUser(caller, row);
|
|
170
|
+
}
|
|
171
|
+
return canEditTaskAsUser(caller, row);
|
|
172
|
+
}
|
|
173
|
+
function canEditTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
|
|
128
174
|
if (!caller) return true;
|
|
129
175
|
const m = memberRow(caller.userId, row.network_id);
|
|
130
176
|
if (!scopedRow(m)) return true;
|
|
131
177
|
if (m?.member_role === "viewer") return false;
|
|
132
178
|
if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return true;
|
|
133
|
-
|
|
179
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return true;
|
|
180
|
+
return departmentGrant(row.network_id, caller.userId, row.project_id) === true || headOf(caller.userId, row);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 这次能改,是不是**只**因为负责人身份(没有自己的 / 按人或部门授权的来源)。这种时候负责人只能把卡交给本部门的人
|
|
185
|
+
* (RFC-040 §1:改负责人只能改成本部门成员 / 其 Agent / 自己),返回本部门的人和节点;否则 null(不加限制)。
|
|
186
|
+
*/
|
|
187
|
+
export function headOnlyEditScope(caller: TaskCaller, row: CardRow): DepartmentCards | null {
|
|
188
|
+
if (!caller) return null;
|
|
189
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
190
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return null;
|
|
191
|
+
if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return null;
|
|
192
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return null;
|
|
193
|
+
if (departmentGrant(row.network_id, caller.userId, row.project_id) === true) return null;
|
|
194
|
+
const cards = headCardsFor(row.network_id, caller.userId);
|
|
195
|
+
return cards && isDepartmentCard(cards, row) ? cards : null;
|
|
134
196
|
}
|
|
135
197
|
|
|
136
198
|
/**
|
|
@@ -142,7 +204,7 @@ export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"
|
|
|
142
204
|
|
|
143
205
|
/** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
|
|
144
206
|
export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
145
|
-
if (!caller) return false;
|
|
207
|
+
if (!caller || caller.node) return false; // 参与人身份是人的;节点按 canEditTask 判
|
|
146
208
|
const m = memberRow(caller.userId, row.network_id);
|
|
147
209
|
if (!scopedRow(m) || m?.member_role === "viewer") return false;
|
|
148
210
|
return participates(row, caller.userId);
|
|
@@ -159,7 +221,15 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
159
221
|
const m = memberRow(caller.userId, row.network_id);
|
|
160
222
|
if (!scopedRow(m)) return true;
|
|
161
223
|
if (m?.member_role === "viewer") return false;
|
|
162
|
-
return ownsCard(row, caller.userId) || createdCard(row, caller.userId);
|
|
224
|
+
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || headOf(caller.userId, row);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 这次能删只因为负责人身份(RFC-040 Q4:记 requirement_deleted_by_leader + 私信卡的负责人)。 */
|
|
228
|
+
export function deletesAsHeadOnly(caller: TaskCaller, row: CardRow): boolean {
|
|
229
|
+
if (!caller) return false;
|
|
230
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
231
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return false;
|
|
232
|
+
return !ownsCard(row, caller.userId) && !createdCard(row, caller.userId) && headOf(caller.userId, row);
|
|
163
233
|
}
|
|
164
234
|
|
|
165
235
|
/**
|
|
@@ -170,14 +240,16 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
170
240
|
export type TaskPermissions = { edit: boolean; delete: boolean; edit_fields?: readonly string[] };
|
|
171
241
|
export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
|
|
172
242
|
if (!caller) return () => null;
|
|
173
|
-
const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean
|
|
243
|
+
const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean>; deptGrants: Map<string, boolean>; head: DepartmentCards | null }>();
|
|
174
244
|
const ctxFor = (networkId: string) => {
|
|
175
245
|
let c = cache.get(networkId);
|
|
176
246
|
if (!c) {
|
|
177
247
|
const m = memberRow(caller.userId, networkId);
|
|
178
248
|
const scoped = scopedRow(m);
|
|
249
|
+
const viewer = m?.member_role === "viewer";
|
|
179
250
|
const grants = new Map<string, boolean>(scoped ? listProjectGrants(networkId, caller.userId).map((g) => [g.project_id, g.can_edit] as [string, boolean]) : []);
|
|
180
|
-
|
|
251
|
+
const deptGrants = scoped ? departmentGrantsForMember(networkId, caller.userId) : new Map<string, boolean>();
|
|
252
|
+
cache.set(networkId, c = { scoped, viewer, grants, deptGrants, head: scoped && !viewer ? headCardsFor(networkId, caller.userId) : null });
|
|
181
253
|
}
|
|
182
254
|
return c;
|
|
183
255
|
};
|
|
@@ -185,8 +257,9 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
|
|
|
185
257
|
const c = ctxFor(row.network_id);
|
|
186
258
|
if (!c.scoped) return null;
|
|
187
259
|
if (c.viewer) return { edit: false, delete: false };
|
|
188
|
-
const
|
|
189
|
-
const
|
|
260
|
+
const head = !!c.head && isDepartmentCard(c.head, row);
|
|
261
|
+
const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId) || head;
|
|
262
|
+
const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true || c.deptGrants.get(row.project_id) === true : false);
|
|
190
263
|
if (!edit && participates(row, caller.userId)) return { edit, delete: mine, edit_fields: PARTICIPANT_EDIT_FIELDS };
|
|
191
264
|
return { edit, delete: mine };
|
|
192
265
|
};
|
|
@@ -199,6 +272,7 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
|
|
|
199
272
|
export function projectUseResolver(caller: TaskCaller, networkId: string): (projectId: string) => boolean {
|
|
200
273
|
if (!caller || !isTaskScoped(caller.userId, networkId)) return () => true;
|
|
201
274
|
const editable = new Set(listProjectGrants(networkId, caller.userId).filter((g) => g.can_edit).map((g) => g.project_id));
|
|
275
|
+
for (const [projectId, canEdit] of departmentGrantsForMember(networkId, caller.userId)) if (canEdit) editable.add(projectId);
|
|
202
276
|
return (projectId) => editable.has(projectId);
|
|
203
277
|
}
|
|
204
278
|
|
|
@@ -206,7 +280,7 @@ export function projectUseResolver(caller: TaskCaller, networkId: string): (proj
|
|
|
206
280
|
export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
|
|
207
281
|
if (!caller || !projectId) return true;
|
|
208
282
|
if (!isTaskScoped(caller.userId, networkId)) return true;
|
|
209
|
-
return projectGrant(networkId, caller.userId, projectId)?.can_edit === true;
|
|
283
|
+
return projectGrant(networkId, caller.userId, projectId)?.can_edit === true || departmentGrant(networkId, caller.userId, projectId) === true;
|
|
210
284
|
}
|
|
211
285
|
|
|
212
286
|
/**
|
|
@@ -214,6 +288,14 @@ export function canUseProject(caller: TaskCaller, networkId: string, projectId:
|
|
|
214
288
|
* 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
|
|
215
289
|
*/
|
|
216
290
|
export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
|
|
291
|
+
const scoped = addTaskVisibilityScopeAsUser(sql, params, caller);
|
|
292
|
+
if (!caller?.node?.only) return scoped;
|
|
293
|
+
// 受限节点:再只留派给它的卡(及其子任务)。
|
|
294
|
+
const pat = params.push(nodeRefPattern(caller.node.id));
|
|
295
|
+
const mine = (t: string) => `${t}agent_owner_json LIKE ?${pat} ESCAPE '\\' OR ${t}participants_json LIKE ?${pat} ESCAPE '\\' OR ${t}created_by_json LIKE ?${pat} ESCAPE '\\'`;
|
|
296
|
+
return `${scoped} AND (${mine("")} OR parent_id IN (SELECT p.requirement_id FROM requirements p WHERE ${mine("p.")}))`;
|
|
297
|
+
}
|
|
298
|
+
function addTaskVisibilityScopeAsUser(sql: string, params: unknown[], caller: TaskCaller): string {
|
|
217
299
|
if (!caller) return sql;
|
|
218
300
|
const scoped = taskScopedNetworks(caller.userId);
|
|
219
301
|
if (!scoped.length) return sql;
|
|
@@ -232,6 +314,16 @@ export function addTaskVisibilityScope(sql: string, params: unknown[], caller: T
|
|
|
232
314
|
parts.push(`(network_id IN (${ph(members)}) AND (${mine} OR ${granted}))`);
|
|
233
315
|
}
|
|
234
316
|
if (viewers.length) parts.push(`(network_id IN (${ph(viewers)}) AND ${granted})`);
|
|
317
|
+
// RFC-040:部门项目授权(viewer 也有,和按人授权一样只给「看」)+ 负责人本部门的卡(viewer 没有)。只加析取项。
|
|
318
|
+
for (const s of scoped) {
|
|
319
|
+
const extra: string[] = [];
|
|
320
|
+
const deptProjects = [...departmentGrantsForMember(s.networkId, caller.userId).keys()];
|
|
321
|
+
if (deptProjects.length) extra.push(`project_id IN (${ph(deptProjects)})`);
|
|
322
|
+
const head = s.viewer ? null : headCardsFor(s.networkId, caller.userId);
|
|
323
|
+
if (head?.users.size) extra.push(`owner_json IN (${ph([...head.users].map((id) => JSON.stringify({ kind: "user", id })))})`);
|
|
324
|
+
if (head?.nodes.size) extra.push(`agent_owner_json IN (${ph([...head.nodes].map((id) => JSON.stringify({ kind: "node", id })))})`);
|
|
325
|
+
if (extra.length) parts.push(`(network_id = ?${params.push(s.networkId)} AND (${extra.join(" OR ")}))`);
|
|
326
|
+
}
|
|
235
327
|
return `${sql} AND (${parts.join(" OR ")})`;
|
|
236
328
|
}
|
|
237
329
|
|
|
@@ -240,7 +332,10 @@ export function addProjectVisibilityScope(sql: string, params: unknown[], caller
|
|
|
240
332
|
if (!caller || !isTaskScoped(caller.userId, networkId)) return sql;
|
|
241
333
|
const uid = params.push(caller.userId);
|
|
242
334
|
const net = params.push(networkId);
|
|
243
|
-
|
|
335
|
+
const personal = `project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
|
|
336
|
+
const deptProjects = [...departmentGrantsForMember(networkId, caller.userId).keys()];
|
|
337
|
+
if (!deptProjects.length) return `${sql} AND ${personal}`;
|
|
338
|
+
return `${sql} AND (${personal} OR project_id IN (${deptProjects.map((id) => `?${params.push(id)}`).join(", ")}))`;
|
|
244
339
|
}
|
|
245
340
|
|
|
246
341
|
export type ReplaceTaskGrantsResult =
|
|
@@ -302,6 +397,7 @@ export function deleteTaskGrantsForMember(networkId: string, userId: string): vo
|
|
|
302
397
|
}
|
|
303
398
|
export function deleteTaskGrantsForProject(projectId: string): void {
|
|
304
399
|
db.run("DELETE FROM network_member_project_grants WHERE project_id = ?1", [projectId]);
|
|
400
|
+
deleteDepartmentGrantsForProject(projectId);
|
|
305
401
|
}
|
|
306
402
|
|
|
307
403
|
// 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。
|
package/src/tools.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
12
12
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
13
13
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
14
14
|
import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
15
|
-
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
|
|
15
|
+
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
16
|
+
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
16
17
|
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
17
18
|
import { errorBody } from "./requirements-errors.js";
|
|
18
19
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
@@ -148,9 +149,9 @@ function scheduledReplyRecipient(taskId: string, networkId: string | null | unde
|
|
|
148
149
|
// 方无影响,只是错误形态更一致)。
|
|
149
150
|
const NODE_ID_ALIAS_FIELDS = {
|
|
150
151
|
node_id: z.string().min(1).max(200).optional()
|
|
151
|
-
.describe("
|
|
152
|
+
.describe("Child node id (as returned by create_node), in your network."),
|
|
152
153
|
child_node_id: z.string().min(1).max(200).optional()
|
|
153
|
-
.describe("
|
|
154
|
+
.describe("Deprecated alias of node_id."),
|
|
154
155
|
} as const;
|
|
155
156
|
|
|
156
157
|
/** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
|
|
@@ -200,11 +201,69 @@ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[],
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
// RFC-041 第一阶段(#487):节点令牌的每个 MCP 工具先按 NODE_TOOL_CLASS 判一次(node-permissions.ts)。
|
|
205
|
+
// log 模式下正常节点照常放行、只记录;显式的只读 / 受限模式立刻生效。requirements_* 在 requirements.ts 里按卡判。
|
|
206
|
+
function guardNodePermissionTools(server: McpServer, tokenId: string, networkId: string): void {
|
|
207
|
+
const denied = (reason: Parameters<typeof nodePermissionDeniedBody>[0], route: string) => ({
|
|
208
|
+
content: [{ type: "text" as const, text: JSON.stringify(nodePermissionDeniedBody(reason, route)) }],
|
|
209
|
+
});
|
|
210
|
+
const taskTarget = (taskId: unknown): { to: AgentRef; from: string | null } | null => {
|
|
211
|
+
if (typeof taskId !== "string" || !taskId) return null;
|
|
212
|
+
const t = db.get<{ to_name: string | null; to_node_id: string | null; from_name: string | null }>(
|
|
213
|
+
"SELECT to_name, to_node_id, from_name FROM tasks WHERE task_id = ?1 AND network_id = ?2", taskId, networkId);
|
|
214
|
+
return t ? { to: { alias: t.to_name, nodeId: t.to_node_id }, from: t.from_name } : null;
|
|
215
|
+
};
|
|
216
|
+
const aliasTarget = (alias: unknown): AgentRef | null => {
|
|
217
|
+
if (typeof alias !== "string" || !alias.trim()) return null;
|
|
218
|
+
const canonical = resolveCanonicalAlias(networkId, alias.trim()).alias;
|
|
219
|
+
const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", canonical, networkId);
|
|
220
|
+
return { alias: canonical, nodeId: session?.node_id ?? null };
|
|
221
|
+
};
|
|
222
|
+
const verdictFor = (id: NodeIdentity, name: string, args: Record<string, unknown>): Verdict => {
|
|
223
|
+
switch (NODE_TOOL_CLASS[name]) {
|
|
224
|
+
case "dispatch": {
|
|
225
|
+
if (name === "retry_task" || name === "cancel_task") {
|
|
226
|
+
const t = taskTarget(args.task_id);
|
|
227
|
+
if (!t) return null; // 没这个任务:交给工具自己回 not found
|
|
228
|
+
if (name === "cancel_task" && t.from && id.aliases.includes(t.from)) return null; // 撤回自己派的
|
|
229
|
+
return dispatchVerdict(id, t.to);
|
|
230
|
+
}
|
|
231
|
+
const target = aliasTarget(name === "reassign_task" ? args.new_alias : args.alias);
|
|
232
|
+
return target ? dispatchVerdict(id, target) : null;
|
|
233
|
+
}
|
|
234
|
+
case "broadcast": return broadcastVerdict(id);
|
|
235
|
+
case "node_write": return writeVerdict(id, name);
|
|
236
|
+
case "human_only": return humanOnlyVerdict(id, name);
|
|
237
|
+
default: return null; // always / read / requirements(按卡判)/ 没归类的(test 保证没有)
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
|
|
241
|
+
const cls = NODE_TOOL_CLASS[name];
|
|
242
|
+
if (cls && cls !== "always" && cls !== "read" && cls !== "requirements") {
|
|
243
|
+
const id = nodeIdentity(tokenId, networkId);
|
|
244
|
+
const args = callArgs[0] && typeof callArgs[0] === "object" ? callArgs[0] as Record<string, unknown> : {};
|
|
245
|
+
const verdict = id ? verdictFor(id, name, args) : null;
|
|
246
|
+
if (id && nodeDecide(id, `mcp:${name}`, verdict)) return denied(verdict!.reason, `mcp:${name}`);
|
|
247
|
+
}
|
|
248
|
+
return handler(...callArgs);
|
|
249
|
+
};
|
|
250
|
+
const anyServer = server as any;
|
|
251
|
+
for (const method of ["tool", "registerTool"] as const) {
|
|
252
|
+
const original = anyServer[method].bind(server);
|
|
253
|
+
anyServer[method] = (...regArgs: any[]) => {
|
|
254
|
+
const last = regArgs.length - 1;
|
|
255
|
+
if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
|
|
256
|
+
return original(...regArgs);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
203
261
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
204
262
|
// 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
|
|
205
263
|
// 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
|
|
206
264
|
const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
|
|
207
265
|
if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
|
|
266
|
+
if (callerTokenIsNetwork && callerTokenId && enforceNetworkId) guardNodePermissionTools(server, callerTokenId, enforceNetworkId);
|
|
208
267
|
// Default from_session for outbound tools — extracted from the calling
|
|
209
268
|
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
210
269
|
// an agent's send_task call always claimed from='hub' and peer agents
|
|
@@ -1639,7 +1698,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1639
1698
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
1640
1699
|
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."),
|
|
1641
1700
|
meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
|
|
1642
|
-
force: z.boolean().optional().describe("
|
|
1701
|
+
force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
|
|
1643
1702
|
},
|
|
1644
1703
|
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);
|
|
1645
1704
|
const effectiveNetId = getNetworkId(netId);
|
|
@@ -1972,7 +2031,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1972
2031
|
// `attachments` before this schema entry existed would see `ok:true`
|
|
1973
2032
|
// and never learn the attachments were dropped. Validated by
|
|
1974
2033
|
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
1975
|
-
attachments: z.any().optional().describe("
|
|
2034
|
+
attachments: z.any().optional().describe("Attachments, like send_task's meta.attachments: [{type:'file', file_id, name?, mime?, size?}]."),
|
|
1976
2035
|
// #507 — optional structured metadata (parity with send_task L837). If
|
|
1977
2036
|
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
1978
2037
|
// top-level wins (same rule as REST /api/task L2101).
|
|
@@ -2359,7 +2418,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2359
2418
|
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
2360
2419
|
from_session: z.string().max(200).optional(),
|
|
2361
2420
|
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
2362
|
-
force: z.boolean().optional().describe("
|
|
2421
|
+
force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
|
|
2363
2422
|
},
|
|
2364
2423
|
async ({ task_id, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2365
2424
|
const effectiveNetId = getNetworkId(netId);
|
|
@@ -2578,7 +2637,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2578
2637
|
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
2579
2638
|
from_session: z.string().max(200).optional(),
|
|
2580
2639
|
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
2581
|
-
force: z.boolean().optional().describe("
|
|
2640
|
+
force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
|
|
2582
2641
|
},
|
|
2583
2642
|
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);
|
|
2584
2643
|
const effectiveNetId = getNetworkId(netId);
|
|
@@ -3509,7 +3568,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3509
3568
|
"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.",
|
|
3510
3569
|
{
|
|
3511
3570
|
...NODE_ID_ALIAS_FIELDS,
|
|
3512
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3571
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
|
|
3513
3572
|
network_id: z.string().max(200).optional(),
|
|
3514
3573
|
},
|
|
3515
3574
|
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
|
|
@@ -3520,7 +3579,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3520
3579
|
"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.",
|
|
3521
3580
|
{
|
|
3522
3581
|
...NODE_ID_ALIAS_FIELDS,
|
|
3523
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3582
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
|
|
3524
3583
|
content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
|
|
3525
3584
|
network_id: z.string().max(200).optional(),
|
|
3526
3585
|
},
|
|
@@ -3532,7 +3591,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3532
3591
|
"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:[…]}.",
|
|
3533
3592
|
{
|
|
3534
3593
|
...NODE_ID_ALIAS_FIELDS,
|
|
3535
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3594
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
|
|
3536
3595
|
network_id: z.string().max(200).optional(),
|
|
3537
3596
|
},
|
|
3538
3597
|
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
|
|
@@ -3543,7 +3602,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3543
3602
|
"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}.",
|
|
3544
3603
|
{
|
|
3545
3604
|
...NODE_ID_ALIAS_FIELDS,
|
|
3546
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3605
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
|
|
3547
3606
|
name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
|
|
3548
3607
|
network_id: z.string().max(200).optional(),
|
|
3549
3608
|
},
|
|
@@ -3555,7 +3614,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3555
3614
|
"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.",
|
|
3556
3615
|
{
|
|
3557
3616
|
...NODE_ID_ALIAS_FIELDS,
|
|
3558
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3617
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
|
|
3559
3618
|
path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
|
|
3560
3619
|
network_id: z.string().max(200).optional(),
|
|
3561
3620
|
},
|
|
@@ -3567,7 +3626,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3567
3626
|
"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.",
|
|
3568
3627
|
{
|
|
3569
3628
|
...NODE_ID_ALIAS_FIELDS,
|
|
3570
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3629
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
|
|
3571
3630
|
path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
|
|
3572
3631
|
network_id: z.string().max(200).optional(),
|
|
3573
3632
|
},
|
|
@@ -3579,7 +3638,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3579
3638
|
"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.",
|
|
3580
3639
|
{
|
|
3581
3640
|
...NODE_ID_ALIAS_FIELDS,
|
|
3582
|
-
alias: z.string().min(1).max(200).optional().describe("
|
|
3641
|
+
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
|
|
3583
3642
|
lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
|
|
3584
3643
|
level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
|
|
3585
3644
|
grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
|
|
@@ -5844,9 +5903,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5844
5903
|
// (requirements.ts personRef;找不到 / 别名对上多个 → 400 + hint)。给了 id 就只看 id。
|
|
5845
5904
|
const reqPerson = z.object({
|
|
5846
5905
|
kind: z.enum(["user", "node"]),
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5906
|
+
// #476 —— 这三格的说明不写在这里:reqPerson 在 3 个工具 × 3 个字段里展开,每份都复制一遍(~1.6 KB)。
|
|
5907
|
+
// 写法在 owner / agent_owner / participants 的 describe 里各说一次。
|
|
5908
|
+
id: z.string().min(1).max(200).optional(),
|
|
5909
|
+
username: z.string().min(1).max(100).optional(),
|
|
5910
|
+
alias: z.string().min(1).max(200).optional(),
|
|
5850
5911
|
});
|
|
5851
5912
|
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() });
|
|
5852
5913
|
const reqFields = {
|
|
@@ -5859,9 +5920,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5859
5920
|
description: z.string().max(20_000).optional().describe("markdown"),
|
|
5860
5921
|
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
5861
5922
|
project_id: z.string().max(200).nullable().optional(),
|
|
5862
|
-
owner: reqPerson.nullable().optional().describe("负责人 (a person): {kind:'user', id}
|
|
5863
|
-
agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id
|
|
5864
|
-
participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole
|
|
5923
|
+
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"),
|
|
5924
|
+
agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id | alias}"),
|
|
5925
|
+
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."),
|
|
5865
5926
|
external_url: z.string().max(500).nullable().optional(),
|
|
5866
5927
|
parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
|
|
5867
5928
|
};
|
|
@@ -5869,14 +5930,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5869
5930
|
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
5870
5931
|
// #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
|
|
5871
5932
|
// 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
|
|
5872
|
-
const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column
|
|
5933
|
+
const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column; must match it if both are sent");
|
|
5873
5934
|
const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
|
|
5874
5935
|
if (args.status === undefined) return call(args);
|
|
5875
5936
|
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 }) }] });
|
|
5876
5937
|
return call({ ...args, column: args.status });
|
|
5877
5938
|
};
|
|
5878
|
-
const reqPeopleDelta = (verb: string) => z.array(reqPerson).max(100).optional().describe(`${verb
|
|
5879
|
-
const REQ_ID_DESC = "requirement id (req_…) or
|
|
5939
|
+
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).`);
|
|
5940
|
+
const REQ_ID_DESC = "requirement id (req_…) or short number \"#N\" (per network; with several networks also pass network_id)";
|
|
5880
5941
|
|
|
5881
5942
|
// #471 —— MCP 的列表默认省流:summary 视图 + 50 张一页(REST 的默认 full + 500 不变,App 靠它)。
|
|
5882
5943
|
// 生产实测 122 张卡:旧默认(full、500)148 KB,summary 81 KB —— 一次调用就塞满 Agent 的上下文。
|
|
@@ -5894,6 +5955,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5894
5955
|
external_ref: z.string().max(200).optional(),
|
|
5895
5956
|
include_archived: z.boolean().optional(),
|
|
5896
5957
|
parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
|
|
5958
|
+
department_id: z.string().max(64).optional().describe("owner (or its Agent's owner) in this department or below"),
|
|
5897
5959
|
top_level: z.boolean().optional(), q: z.string().max(200).optional(),
|
|
5898
5960
|
limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
|
|
5899
5961
|
cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
|
|
@@ -5904,7 +5966,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5904
5966
|
server.registerTool(
|
|
5905
5967
|
"requirements_list",
|
|
5906
5968
|
{
|
|
5907
|
-
description: `List requirement tasks in your network. Defaults (MCP): view='summary'
|
|
5969
|
+
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).`,
|
|
5908
5970
|
// Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
|
|
5909
5971
|
inputSchema: z.strictObject(reqListShape, {
|
|
5910
5972
|
error: (iss: any) => iss.code === "unrecognized_keys"
|
|
@@ -5914,7 +5976,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5914
5976
|
},
|
|
5915
5977
|
async (args: any) => {
|
|
5916
5978
|
const q = new URLSearchParams();
|
|
5917
|
-
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]));
|
|
5979
|
+
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]));
|
|
5918
5980
|
if (!q.has("view")) q.set("view", "summary");
|
|
5919
5981
|
if (!q.has("limit")) q.set("limit", String(REQ_LIST_MCP_DEFAULT_LIMIT));
|
|
5920
5982
|
if (args.include_archived) q.set("include_archived", "1");
|
|
@@ -5935,7 +5997,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5935
5997
|
server.registerTool(
|
|
5936
5998
|
"requirements_people",
|
|
5937
5999
|
{
|
|
5938
|
-
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.
|
|
6000
|
+
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}.",
|
|
5939
6001
|
inputSchema: z.strictObject(reqPeopleShape, {
|
|
5940
6002
|
error: (iss: any) => iss.code === "unrecognized_keys"
|
|
5941
6003
|
? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqPeopleKeys.join(", ")}`
|
|
@@ -6020,17 +6082,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6020
6082
|
async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived"])),
|
|
6021
6083
|
);
|
|
6022
6084
|
|
|
6023
|
-
|
|
6085
|
+
// #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
|
|
6086
|
+
const REQ_EVENTS_NETWORK_DEFAULT_LIMIT = 50;
|
|
6087
|
+
const reqEventsShape = {
|
|
6088
|
+
network_id: z.string().max(200).optional(),
|
|
6089
|
+
requirement_id: z.string().min(1).max(200).optional().describe(REQ_ID_DESC),
|
|
6090
|
+
since: z.string().max(40).optional().describe("ISO; only events after it"),
|
|
6091
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
6092
|
+
cursor: z.string().max(20).optional().describe("next_cursor from the previous page (older events)"),
|
|
6093
|
+
};
|
|
6094
|
+
const reqEventsKeys = Object.keys(reqEventsShape);
|
|
6095
|
+
server.registerTool(
|
|
6024
6096
|
"requirements_events",
|
|
6025
|
-
"Read the task activity timeline (the app's 动态): field-level changes — who, when, old → new — and comments (kind=comment, text in new.text; add one with requirements_comment), 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.",
|
|
6026
6097
|
{
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6098
|
+
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).`,
|
|
6099
|
+
inputSchema: z.strictObject(reqEventsShape, {
|
|
6100
|
+
error: (iss: any) => iss.code === "unrecognized_keys"
|
|
6101
|
+
? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqEventsKeys.join(", ")}`
|
|
6102
|
+
: undefined,
|
|
6103
|
+
}) as any,
|
|
6032
6104
|
},
|
|
6033
|
-
async (args) => {
|
|
6105
|
+
async (args: any) => {
|
|
6034
6106
|
let card = args.requirement_id;
|
|
6035
6107
|
// "#N" → the card's id, resolved exactly like requirements_get (same scope; a card you can't see is a 404 here too).
|
|
6036
6108
|
if (card && /^#\d+$/.test(card)) {
|
|
@@ -6043,6 +6115,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6043
6115
|
if (card) q.set("requirement_id", card);
|
|
6044
6116
|
if (args.since) q.set("since", args.since);
|
|
6045
6117
|
if (args.limit !== undefined) q.set("limit", String(args.limit));
|
|
6118
|
+
else if (!card) q.set("limit", String(REQ_EVENTS_NETWORK_DEFAULT_LIMIT));
|
|
6046
6119
|
if (args.cursor) q.set("cursor", args.cursor);
|
|
6047
6120
|
return requirementsCall("GET", `/api/requirements/events${q.size ? `?${q}` : ""}`, args.network_id);
|
|
6048
6121
|
},
|