@sleep2agi/commhub-server 0.6.0 → 0.8.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
3
  import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
4
- import { pushEvent, pushBroadcast } from "./push.js";
4
+ import { pushEvent } from "./push.js";
5
5
  import { getUserNetworkRole } from "./auth.js";
6
6
 
7
7
  function ts(): string {
@@ -36,6 +36,44 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
36
36
  return sql;
37
37
  };
38
38
 
39
+ type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
40
+
41
+ const getReadableNetworkIds = (): string[] => {
42
+ if (!enforceUserId) return [];
43
+ return db.all<{ network_id: string }>(
44
+ "SELECT network_id FROM network_members WHERE user_id = ?1",
45
+ enforceUserId
46
+ ).map((row) => row.network_id);
47
+ };
48
+
49
+ const resolveReadScope = (clientNetId?: string | null): ReadScope => {
50
+ if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
51
+ if (enforceNetworkId) {
52
+ const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
53
+ return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
54
+ }
55
+ if (clientNetId) {
56
+ const role = getUserNetworkRole(enforceUserId, clientNetId);
57
+ return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
58
+ }
59
+ return { networkId: null, networkIds: getReadableNetworkIds() };
60
+ };
61
+
62
+ const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
63
+ if (scope.networkId) {
64
+ sql += ` AND ${column} = ?${params.length + 1}`;
65
+ params.push(scope.networkId);
66
+ return sql;
67
+ }
68
+ if (scope.networkIds) {
69
+ if (scope.networkIds.length === 0) return `${sql} AND 1=0`;
70
+ const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
71
+ sql += ` AND ${column} IN (${placeholders})`;
72
+ params.push(...scope.networkIds);
73
+ }
74
+ return sql;
75
+ };
76
+
39
77
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
40
78
  const params: any[] = [alias];
41
79
  let sql = "SELECT status FROM sessions WHERE alias = ?1";
@@ -74,6 +112,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
74
112
  },
75
113
  async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId }) => {
76
114
  const effectiveNetId = getNetworkId(netId);
115
+ const sessionNetId = effectiveNetId ?? "default";
77
116
  if (!canWrite(effectiveNetId)) {
78
117
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
79
118
  }
@@ -82,11 +121,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
82
121
 
83
122
  db.transaction(() => {
84
123
  // Only delete same-alias sessions within the same network
85
- if (effectiveNetId) {
86
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
87
- } else {
88
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
89
- }
124
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, sessionNetId]);
90
125
  db.run(
91
126
  `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, last_seen_at, updated_at)
92
127
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
@@ -101,7 +136,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
101
136
  session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
102
137
  channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
103
138
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
104
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, effectiveNetId ?? null]
139
+ [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId]
105
140
  );
106
141
  });
107
142
 
@@ -244,7 +279,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
244
279
  [parentChain.parent_task_id]
245
280
  );
246
281
  if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
247
- pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias });
282
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias }, effectiveNetId);
248
283
  }
249
284
  }
250
285
  } catch (e: any) {
@@ -266,16 +301,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
266
301
  limit: z.number().min(1).max(100).optional().default(10),
267
302
  },
268
303
  async ({ alias, limit }) => {
269
- const effectiveNetId = getNetworkId(null);
304
+ const readScope = resolveReadScope(null);
305
+ if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
270
306
  const countParams: any[] = [alias];
271
307
  let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
272
- countSql = addScope(countSql, countParams, effectiveNetId);
308
+ countSql = addReadScope(countSql, countParams, readScope);
273
309
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
274
310
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
275
311
  const rowsParams: any[] = [alias];
276
312
  let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
277
313
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
278
- rowsSql = addScope(rowsSql, rowsParams, effectiveNetId);
314
+ rowsSql = addReadScope(rowsSql, rowsParams, readScope);
279
315
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
280
316
  LIMIT ?${rowsParams.length + 1}`;
281
317
  rowsParams.push(limit);
@@ -335,19 +371,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
335
371
  network_id: z.string().max(200).optional().describe("Filter by network"),
336
372
  },
337
373
  async ({ filter_status, filter_server, network_id: netId }) => {
338
- const effectiveNetId = getNetworkId(netId);
339
- console.log(`[${ts()}] hub get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
374
+ const readScope = resolveReadScope(netId);
375
+ if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
376
+ console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
340
377
 
341
378
  const sessions = db.transaction(() => {
342
379
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
343
380
  const staleParams: any[] = [cutoff];
344
381
  let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
345
- staleSql = addScope(staleSql, staleParams, effectiveNetId);
382
+ staleSql = addReadScope(staleSql, staleParams, readScope);
346
383
  db.run(staleSql, staleParams);
347
384
 
348
385
  let sql = "SELECT * FROM sessions WHERE 1=1";
349
386
  const params: any[] = [];
350
- if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
387
+ sql = addReadScope(sql, params, readScope);
351
388
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
352
389
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
353
390
  sql += " ORDER BY updated_at DESC";
@@ -356,7 +393,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
356
393
 
357
394
  const summaryParams: any[] = [];
358
395
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
359
- summarySql = addScope(summarySql, summaryParams, effectiveNetId);
396
+ summarySql = addReadScope(summarySql, summaryParams, readScope);
360
397
  summarySql += " GROUP BY status";
361
398
  const summary = db.all(summarySql, ...summaryParams);
362
399
 
@@ -376,21 +413,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
376
413
  "Get detailed status of a specific session by alias.",
377
414
  { alias: z.string().min(1).max(200).describe("Session alias") },
378
415
  async ({ alias }) => {
379
- const effectiveNetId = getNetworkId(null);
416
+ const readScope = resolveReadScope(null);
417
+ if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
380
418
  console.log(`[${ts()}] hub → get_session_status: ${alias}`);
381
419
  const sessionParams: any[] = [alias];
382
420
  let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
383
- sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
421
+ sessionSql = addReadScope(sessionSql, sessionParams, readScope);
384
422
  const session = db.get(sessionSql, ...sessionParams);
385
423
 
386
424
  const pendingParams: any[] = [alias];
387
425
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
388
- pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
426
+ pendingSql = addReadScope(pendingSql, pendingParams, readScope);
389
427
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
390
428
 
391
429
  const recentParams: any[] = [alias];
392
430
  let recentSql = "SELECT * FROM completions WHERE session_name = ?1";
393
- recentSql = addScope(recentSql, recentParams, effectiveNetId);
431
+ recentSql = addReadScope(recentSql, recentParams, readScope);
394
432
  recentSql += " ORDER BY completed_at DESC LIMIT 5";
395
433
  const recent = db.all(recentSql, ...recentParams);
396
434
 
@@ -488,7 +526,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
488
526
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
489
527
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
490
528
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
491
- pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
529
+ pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session }, effectiveNetId);
492
530
 
493
531
  return {
494
532
  content: [
@@ -526,7 +564,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
526
564
 
527
565
  const session = scopedSessionStatus(alias, effectiveNetId);
528
566
 
529
- pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
567
+ pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
530
568
 
531
569
  return {
532
570
  content: [
@@ -601,7 +639,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
601
639
  [parentChain.parent_task_id]
602
640
  );
603
641
  if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
604
- pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias });
642
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias }, effectiveNetId);
605
643
  }
606
644
  }
607
645
  } catch (e: any) {
@@ -610,7 +648,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
610
648
  }
611
649
 
612
650
  const session = scopedSessionStatus(alias, effectiveNetId);
613
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
651
+ pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
614
652
 
615
653
  return {
616
654
  content: [{
@@ -687,7 +725,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
687
725
  });
688
726
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
689
727
  // SSE push (unconditional — channel is keyed by alias, not network)
690
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
728
+ pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
691
729
  return {
692
730
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
693
731
  };
@@ -702,10 +740,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
702
740
  task_id: z.string().min(1).max(200).describe("Task ID to query"),
703
741
  },
704
742
  async ({ task_id }) => {
705
- const effectiveNetId = getNetworkId(null);
743
+ const readScope = resolveReadScope(null);
744
+ if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
706
745
  const params: any[] = [task_id];
707
746
  let sql = "SELECT * FROM tasks WHERE task_id = ?1";
708
- sql = addScope(sql, params, effectiveNetId);
747
+ sql = addReadScope(sql, params, readScope);
709
748
  const task = db.get<any>(sql, ...params);
710
749
  return {
711
750
  content: [{
@@ -728,10 +767,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
728
767
  limit: z.number().min(1).max(100).optional().default(20),
729
768
  },
730
769
  async ({ alias, status, from_name, network_id: netId, limit }) => {
731
- const effectiveNetId = getNetworkId(netId);
770
+ const readScope = resolveReadScope(netId);
771
+ if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
732
772
  let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
733
773
  const params: any[] = [];
734
- if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
774
+ sql = addReadScope(sql, params, readScope);
735
775
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
736
776
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
737
777
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
@@ -742,7 +782,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
742
782
  // Stats
743
783
  const statsParams: any[] = [];
744
784
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
745
- statsSql = addScope(statsSql, statsParams, effectiveNetId);
785
+ statsSql = addReadScope(statsSql, statsParams, readScope);
746
786
  statsSql += " GROUP BY status";
747
787
  const stats = db.all(statsSql, ...statsParams);
748
788
 
@@ -826,7 +866,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
826
866
  [newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
827
867
  });
828
868
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
829
- pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
869
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
830
870
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
831
871
  }
832
872
  );
@@ -863,7 +903,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
863
903
  ids.push(id);
864
904
  }
865
905
 
866
- pushBroadcast(targets.map(t => t.alias), { type: "broadcast", inbox_count: 1, message: message.slice(0, 200) });
906
+ for (const t of targets) {
907
+ pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
908
+ }
867
909
 
868
910
  return {
869
911
  content: [
@@ -886,12 +928,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
886
928
  limit: z.number().min(1).max(500).optional().default(50),
887
929
  },
888
930
  async ({ since, alias, network_id: netId, limit }) => {
889
- const effectiveNetId = getNetworkId(netId);
931
+ const readScope = resolveReadScope(netId);
932
+ if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
890
933
  console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
891
934
  const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
892
935
  let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
893
936
  const params: any[] = [cutoff];
894
- sql = addScope(sql, params, effectiveNetId);
937
+ sql = addReadScope(sql, params, readScope);
895
938
 
896
939
  if (alias) {
897
940
  sql += ` AND session_name = ?${params.length + 1}`;