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