@sleep2agi/commhub-server 0.9.0-preview.116 → 0.9.0-preview.118

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.116",
3
+ "version": "0.9.0-preview.118",
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,98 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+
4
+ process.env.COMMHUB_DB ||= `${mkdtempSync('/tmp/team-whoami-')}/hub.db`;
5
+ let db: any, hub: any, base: string, owner: any, outside: any;
6
+ let selfToken: string, otherToken: string, outsideToken: string, unboundToken: string;
7
+ const SELF = 'node_team_whoami_self', OTHER = 'node_team_whoami_other', FOREIGN = 'node_team_whoami_foreign';
8
+ const TEAM = 'team_whoami_child', ROOT = 'team_whoami_root';
9
+
10
+ async function rpc(token: string, method = 'tools/call', params: unknown = { name: 'org_whoami', arguments: {} }) {
11
+ const response = await fetch(`${base}/mcp`, { method: 'POST', headers: {
12
+ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
13
+ Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-03-26',
14
+ }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
15
+ expect(response.status).toBe(200);
16
+ const text = await response.text();
17
+ const line = text.split('\n').filter(x => x.startsWith('data:')).at(-1);
18
+ const body = JSON.parse(line ? line.slice(5) : text);
19
+ expect(body.error).toBeUndefined();
20
+ return body.result;
21
+ }
22
+ async function who(token = selfToken, args: Record<string, unknown> = {}) {
23
+ const result = await rpc(token, 'tools/call', { name: 'org_whoami', arguments: args });
24
+ return JSON.parse(result.content[0].text);
25
+ }
26
+
27
+ beforeAll(async () => {
28
+ ({ db } = await import('./db.js'));
29
+ const { register, createNetworkTokenForNode } = await import('./auth.js');
30
+ owner = register('team_whoami_owner', 'Fixture-Passw0rd!xyz');
31
+ outside = register('team_whoami_outside', 'Fixture-Passw0rd!xyz');
32
+ db.run("UPDATE users SET display_name = 'Team owner' WHERE user_id = ?1", [owner.user.user_id]);
33
+ const mint = (u: any, alias: string, node?: string) => {
34
+ const r = createNetworkTokenForNode(u.user.user_id, u.network_id, alias, node);
35
+ expect(r.error ?? null).toBe(null); return r.token!;
36
+ };
37
+ selfToken = mint(owner, 'team-self', SELF);
38
+ otherToken = mint(owner, 'team-other', OTHER);
39
+ outsideToken = mint(outside, 'team-self', FOREIGN); // Same alias in a different network.
40
+ unboundToken = mint(owner, 'team-unregistered');
41
+ const { bootServer } = await import('./server.js');
42
+ hub = bootServer({ port: 0, hostname: '127.0.0.1' });
43
+ base = `http://127.0.0.1:${hub.port}`;
44
+ for (const [net, id, name, parent] of [
45
+ [owner.network_id, ROOT, 'Parent', null], [owner.network_id, TEAM, 'My team', ROOT],
46
+ [outside.network_id, TEAM, 'Foreign team', null],
47
+ ]) db.run('INSERT INTO network_agent_teams(network_id,team_id,name,parent_id) VALUES(?1,?2,?3,?4)', [net,id,name,parent]);
48
+ db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]);
49
+ for (const [net, node] of [[owner.network_id, SELF], [outside.network_id, FOREIGN]]) {
50
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [net,node,TEAM]);
51
+ }
52
+ // Even a stale/corrupt membership must not project a node from another network.
53
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,FOREIGN,TEAM]);
54
+ }, 60_000);
55
+ afterAll(() => hub?.stop(true));
56
+
57
+ test('exact node team, nearest-first ancestors, Agent lead and private human display name', async () => {
58
+ const r = await who();
59
+ expect(r.ok).toBe(true); expect(r.source).toBe('node'); expect(r.node_id).toBe(SELF);
60
+ expect(r.team).toEqual({ id: TEAM, name: 'My team' });
61
+ expect(r.ancestors).toEqual([{ id: ROOT, name: 'Parent' }]);
62
+ expect(r.lead.node_id).toBe(SELF);
63
+ expect(r.owner).toEqual({ user_id: owner.user.user_id, display_name: 'Team owner' });
64
+ expect(r.agents.map((n: any) => n.node_id)).toEqual([SELF]);
65
+ expect(r.truncated).toBe(false);
66
+ expect(JSON.stringify(r)).not.toContain('team_whoami_owner');
67
+ });
68
+ test('same-id foreign team and foreign node cannot cross network; supplied identity is ignored', async () => {
69
+ const r = await who(selfToken, { network_id: outside.network_id, node_id: FOREIGN, alias: 'team-other' });
70
+ expect(r.node_id).toBe(SELF); expect(r.team.name).toBe('My team');
71
+ expect(JSON.stringify(r)).not.toContain(FOREIGN);
72
+ const b = await who(outsideToken);
73
+ expect(b.team.name).toBe('Foreign team'); expect(b.node_id).toBe(FOREIGN);
74
+ expect(b.ancestors).toEqual([]); expect(JSON.stringify(b)).not.toContain(SELF);
75
+ });
76
+ test('unassigned node does not inherit its owner or another node team', async () => {
77
+ expect(await who(otherToken)).toEqual({ ok: true, source: 'none', node_id: OTHER, team: null,
78
+ ancestors: [], lead: null, owner: null, agents: [], truncated: false });
79
+ expect((await who(unboundToken)).error).toBe('node_identity_unbound');
80
+ });
81
+ test('user credentials cannot list or call; node credentials can list', async () => {
82
+ expect((await rpc(owner.token, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(false);
83
+ expect((await who(owner.token)).error).toBe('network_token_required');
84
+ expect((await rpc(selfToken, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(true);
85
+ });
86
+ test('foreign lead and human owner references are not returned', async () => {
87
+ db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [FOREIGN,outside.user.user_id,owner.network_id,TEAM]);
88
+ try { const r = await who(); expect(r.lead).toBe(null); expect(r.owner).toBe(null); }
89
+ finally { db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]); }
90
+ });
91
+ test('large teams are bounded to 50 and explicitly truncated', async () => {
92
+ for (let i = 0; i < 51; i++) {
93
+ const id = `node_team_whoami_cap_${i}`;
94
+ db.run('INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?1,?2)', [id,owner.network_id]);
95
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,id,TEAM]);
96
+ }
97
+ const r = await who(); expect(r.agents.length).toBe(50); expect(r.truncated).toBe(true);
98
+ });
@@ -125,6 +125,28 @@ export function listAgentTeams(networkId: string, hidden: Hidden) {
125
125
  }));
126
126
  }
127
127
 
128
+ /** #765: node-token identity only; no fallback to a human's department.
129
+ * Reuse the REST projection's network joins and display-name privacy rules.
130
+ */
131
+ export function agentTeamWhoami(networkId: string, nodeId: string | null) {
132
+ if (!nodeId) return { ok: false, error: "node_identity_unbound" };
133
+ const teams = listAgentTeams(networkId, () => false);
134
+ const self = teams.find(t => t.members.some(n => n.node_id === nodeId));
135
+ if (!self) return { ok: true, source: "none", node_id: nodeId, team: null, ancestors: [], lead: null, owner: null, agents: [], truncated: false };
136
+ const byId = new Map(teams.map(t => [t.id, t]));
137
+ const seen = new Set([self.id]);
138
+ const ancestors: { id: string; name: string }[] = [];
139
+ for (let id = self.parent_id; id && !seen.has(id);) {
140
+ const parent = byId.get(id);
141
+ if (!parent) break;
142
+ seen.add(id);
143
+ ancestors.push({ id, name: parent.name });
144
+ id = parent.parent_id;
145
+ }
146
+ return { ok: true, source: "node", node_id: nodeId, team: { id: self.id, name: self.name }, ancestors,
147
+ lead: self.lead, owner: self.owner, agents: self.members.slice(0, 50), truncated: self.members.length > 50 };
148
+ }
149
+
128
150
  /** scope = null 表示全管;否则是调用者能管的团队集合。 */
129
151
  export function createAgentTeam(networkId: string, actor: string, scope: Set<string> | null, hidden: Hidden, body: Record<string, unknown>) {
130
152
  const name = cleanName(body.name);
@@ -257,7 +257,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
257
257
  get_adopt_request: "always", ack_adopt_request: "always",
258
258
  send_desktop_message: "always", // 发给人的私信,不是派活
259
259
  // 读
260
- get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
260
+ get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
261
261
  list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
262
262
  get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
263
263
  read_node_file: "read", tail_node_logs: "read",
@@ -271,6 +271,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
271
271
  // 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
272
272
  schedule_list: "read", schedule_get: "read", schedule_runs: "read",
273
273
  schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
274
+ schedule_batch_interval: "schedule",
274
275
  // 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
275
276
  update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
276
277
  stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
@@ -161,6 +161,72 @@ describe("happy path from a node", () => {
161
161
  });
162
162
  });
163
163
 
164
+ describe("batch interval updates (#816)", () => {
165
+ const batch = (token: string, schedule_ids: string[], every_seconds = 600) =>
166
+ mcp(token, "schedule_batch_interval", { schedule_ids, every_seconds });
167
+ const get = async (id: string) => (await mcp(node.b.token, "schedule_get", { schedule_id: id })).schedule;
168
+
169
+ test("two updates, deduplicated IDs, paused stays paused, unselected stays untouched", async () => {
170
+ const a = (await create("b")).schedule, b = (await create("b")).schedule;
171
+ const untouched = (await create("b")).schedule;
172
+ await mcp(node.b.token, "schedule_update", { schedule_id: b.schedule_id, status: "paused" });
173
+ const before = Date.now();
174
+ const result = await batch(node.b.token, [a.schedule_id, b.schedule_id, a.schedule_id]);
175
+ expect(result).toMatchObject({ ok: true, updated: 2, failed: 0 });
176
+ expect(result.results).toHaveLength(2);
177
+ expect(result.results[0]).toMatchObject({ schedule_id: a.schedule_id, every_seconds: 600, revision: a.revision + 1, status: "active" });
178
+ expect(Date.parse(result.results[0].next_run_at)).toBeGreaterThanOrEqual(before + 600_000);
179
+ expect(result.results[1]).toMatchObject({ status: "paused", next_run_at: null, every_seconds: 600 });
180
+ for (const original of [a, b]) {
181
+ expect(await get(original.schedule_id)).toMatchObject({ name: original.name, task_content: original.task_content, target_node_id: original.target_node_id, schedule: { type: "interval", every_seconds: 600 } });
182
+ }
183
+ expect(await get(untouched.schedule_id)).toEqual(untouched);
184
+ });
185
+
186
+ test("partial results preserve human, foreign-network, daily and cancelled schedules", async () => {
187
+ const own = (await create("b")).schedule;
188
+ const daily = (await create("b", { schedule: { type: "daily", time: "09:30" } })).schedule;
189
+ const cancelled = (await create("b")).schedule;
190
+ await mcp(node.b.token, "schedule_cancel", { schedule_id: cancelled.schedule_id });
191
+ const human = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "human batch", task: "t", target_node_id: node.b.id, schedule: EVERY_MIN });
192
+ const foreign = (await create("x")).schedule;
193
+ const ids = [human.body.schedule.schedule_id, foreign.schedule_id, "no-such-schedule", daily.schedule_id, cancelled.schedule_id, own.schedule_id];
194
+ const result = await batch(node.b.token, ids);
195
+ expect(result).toMatchObject({ ok: false, updated: 1, failed: 5 });
196
+ expect(result.results.map((r: any) => r.error ?? "ok")).toEqual(["not_schedule_creator", "schedule_not_found", "schedule_not_found", "not_interval_schedule", "schedule_cancelled", "ok"]);
197
+ expect((await get(ids[0])).schedule).toEqual(EVERY_MIN);
198
+ expect((await mcp(node.x.token, "schedule_get", { schedule_id: foreign.schedule_id })).schedule).toEqual(foreign);
199
+ expect(await get(daily.schedule_id)).toEqual(daily);
200
+ expect((await get(cancelled.schedule_id)).status).toBe("cancelled");
201
+ });
202
+
203
+ test("invalid interval or batch shape performs no writes; node-only and readonly gates remain", async () => {
204
+ const own = (await create("b")).schedule, ids = [own.schedule_id];
205
+ for (const seconds of [59, 60.5, 365 * 86400 + 1]) {
206
+ expect(await batch(node.b.token, ids, seconds)).toMatchObject({ ok: false, error: "invalid_interval" });
207
+ }
208
+ for (const invalid of [[], Array(101).fill(own.schedule_id), [""]]) {
209
+ expect((await batch(node.b.token, invalid)).ok).not.toBe(true);
210
+ }
211
+ expect((await batch(user.boss.token, ids)).error).toBe("network_token_required");
212
+ expect(await batch(node.ro.token, ids)).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
213
+ expect(await get(own.schedule_id)).toEqual(own);
214
+ });
215
+
216
+ test("creator still needs permission to reach the current target", async () => {
217
+ const own = (await create("b", { target_node_id: node.a.id })).schedule;
218
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.rst.id, node.b.id]);
219
+ try {
220
+ const result = await batch(node.b.token, [own.schedule_id]);
221
+ expect(result).toMatchObject({ ok: false, updated: 0, failed: 1 });
222
+ expect(result.results[0]).toMatchObject({ reason: "agent_not_granted_to_owner" });
223
+ expect(await get(own.schedule_id)).toEqual(own);
224
+ } finally {
225
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.boss.id, node.b.id]);
226
+ }
227
+ });
228
+ });
229
+
164
230
  describe("denials", () => {
165
231
  test("another network: cannot target, see or touch it", async () => {
166
232
  const mine = (await create("a")).schedule.schedule_id;
@@ -23,7 +23,7 @@
23
23
  import { db } from "./db.js";
24
24
  import {
25
25
  cancelSchedule, decodeRowForAgent, dispatchScheduledOccurrence, getScheduleRow, insertSchedule, patchSchedule,
26
- scheduleRuns, ScheduleInputError, validateScheduleCreate, type ScheduledRow,
26
+ scheduleRuns, ScheduleInputError, validateScheduleCreate, parseScheduleSpec, type ScheduledRow,
27
27
  } from "./scheduled-tasks.js";
28
28
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
29
29
  import { dispatchVerdict, nodePermissionDeniedBody, recordNodePermission, type NodeIdentity } from "./node-permissions.js";
@@ -145,6 +145,25 @@ export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, schedul
145
145
  } catch (e) { return inputError(e); }
146
146
  }
147
147
 
148
+ /** #816: partial success, one result per distinct ID; no change to existing write authority. */
149
+ export function agentBatchScheduleInterval(id: NodeIdentity, canWrite: boolean, scheduleIds: string[], everySeconds: number): AgentScheduleResult {
150
+ const gate = writeGate(id, canWrite, "mcp:schedule_batch_interval");
151
+ if (gate) return gate;
152
+ const schedule = { type: "interval", every_seconds: everySeconds };
153
+ try { parseScheduleSpec(schedule, "UTC"); } catch (e) { return inputError(e); }
154
+ const results = [...new Set(scheduleIds)].map(schedule_id => {
155
+ const row = ownRow(id, schedule_id);
156
+ if (!isRow(row)) return { schedule_id, ...row };
157
+ if (row.schedule_type !== "interval") return { schedule_id, ok: false, error: "not_interval_schedule" };
158
+ const result = agentUpdateSchedule(id, canWrite, schedule_id, { schedule, base_revision: row.revision });
159
+ if (!result.ok) return { schedule_id, ...result };
160
+ const updated = result.schedule as ReturnType<typeof decodeRowForAgent>;
161
+ return { schedule_id, ok: true, every_seconds: everySeconds, next_run_at: updated.next_run_at, status: updated.status, revision: updated.revision };
162
+ });
163
+ const updated = results.filter(r => r.ok).length;
164
+ return { ok: updated === results.length, updated, failed: results.length - updated, results };
165
+ }
166
+
148
167
  export function agentCancelSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
149
168
  const gate = writeGate(id, canWrite, "mcp:schedule_cancel");
150
169
  if (gate) return gate;
@@ -43,6 +43,7 @@ const OVERRIDES: Record<string, Record<string, unknown>> = {
43
43
  mark_tasks_consumed: { task_ids: ["task_probe"] },
44
44
  upsert_network_secret: { key: "PROBE_KEY" },
45
45
  update_provider: { provider_id: "prov_probe" },
46
+ schedule_batch_interval: { schedule_ids: ["sched_probe"], every_seconds: 60 },
46
47
  };
47
48
  function fill(schema: any, key: string, ctx: Record<string, unknown>): unknown {
48
49
  if (!schema) return "probe";
@@ -26,10 +26,10 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
26
26
  request_adopt_node: "user", unadopt_node: "user",
27
27
  get_adopt_request: "protocol", ack_adopt_request: "protocol",
28
28
  // ── 节点:用户令牌一律 network_token_required ──
29
- report_status: "node",
29
+ report_status: "node", org_whoami: "node",
30
30
  // #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
31
31
  schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
32
- schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
32
+ schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node", schedule_batch_interval: "node",
33
33
  // ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
34
34
  mark_tasks_runtime_submitted: "protocol", mark_tasks_consumed: "protocol",
35
35
  get_config_update: "protocol", ack_config_update: "protocol",
package/src/tools.ts CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
3
3
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
4
4
  import { lockNodeAlias } from "./node-token-ownership.js";
5
- import { clearNodeFromAgentTeams } from "./agent-teams.js";
5
+ import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
6
6
  import { parseDbTimestampMs } from "./db-timestamp.js";
7
7
  import { z } from "zod/v4";
8
8
  import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
@@ -21,7 +21,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
21
21
  import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
22
22
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
23
23
  import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
24
- import { agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
24
+ import { agentBatchScheduleInterval, agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
25
25
  import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
26
26
  import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
27
27
  import { errorBody } from "./requirements-errors.js";
@@ -6396,6 +6396,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6396
6396
  server.tool("schedule_update", "Edit your schedule; status=paused|active.",
6397
6397
  { ...schedFields, schedule_id: schedId, base_revision: z.number().int().min(0).optional().describe("Reject if current revision differs."), name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
6398
6398
  async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
6399
+ server.tool("schedule_batch_interval", "Batch interval edit; per-ID results.",
6400
+ { schedule_ids: z.array(z.string()).min(1).max(100), every_seconds: z.number() },
6401
+ async ({ schedule_ids, every_seconds }) => scheduleCall((id, w) => agentBatchScheduleInterval(id, w, schedule_ids, every_seconds)));
6399
6402
  server.tool("schedule_cancel", "Cancel your schedule.", { schedule_id: schedId },
6400
6403
  async ({ schedule_id }) => scheduleCall((id, w) => agentCancelSchedule(id, w, schedule_id)));
6401
6404
  server.tool("schedule_run_now", "Run your schedule now.", { schedule_id: schedId },
@@ -6403,6 +6406,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6403
6406
  server.tool("schedule_runs", "A schedule's recent runs.", { schedule_id: schedId },
6404
6407
  async ({ schedule_id }) => scheduleCall(id => agentScheduleRuns(id, schedule_id)));
6405
6408
 
6409
+ // #765: exact authenticated node; team tree is independent of human departments.
6410
+ server.tool("org_whoami", "Your Agent team, ancestors, lead, owner and up to 50 teammates. Read-only.", {},
6411
+ async () => scheduleCall(id => agentTeamWhoami(id.networkId, id.nodeId)));
6412
+
6406
6413
  // #478:tools/list 只列这个调用者真能用的工具(tool-audience.ts)。只滤列表,tools/call 不变。
6407
6414
  const caller: ToolCaller = callerTokenIsNetwork
6408
6415
  ? { kind: "node", tokenId: callerTokenId ?? null, networkId: enforceNetworkId ?? null, includeProtocol: listing.includeProtocol === true }