@sleep2agi/commhub-server 0.5.0-preview.33 → 0.5.0-preview.35

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +15 -12
  3. package/src/tools.ts +19 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.33",
3
+ "version": "0.5.0-preview.35",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -10,6 +10,14 @@ const PORT = Number(process.env.PORT) || 9200;
10
10
  const HOST = process.env.HOST || "0.0.0.0";
11
11
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
12
12
 
13
+ // Read version from package.json so banners and /health stay in sync.
14
+ const SERVER_VERSION = (() => {
15
+ try {
16
+ const url = new URL("../package.json", import.meta.url);
17
+ return JSON.parse(require("fs").readFileSync(url, "utf8")).version || "?";
18
+ } catch { return "?"; }
19
+ })();
20
+
13
21
  // ── Rate limiter (in-memory, per IP) ──
14
22
  const rateLimits = new Map<string, { count: number; resetAt: number }>();
15
23
  function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
@@ -228,17 +236,12 @@ Bun.serve({
228
236
  if (authErr) return withCors(req, authErr);
229
237
  const fwd = req.headers.get("x-forwarded-for");
230
238
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
231
- // V3: resolve token → enforce network_id in all MCP tools
239
+ // V3: resolve token → enforce network_id in all MCP tools.
240
+ // utok_ (user token, not network-bound) is allowed — the tool layer
241
+ // scopes to the user's accessible networks. Without this Dashboard
242
+ // (which logs in as a user) cannot call send_task.
232
243
  const authCtx = resolveRequestAuth(req);
233
244
  const enforceNetId = authCtx?.networkId || null;
234
- // utok_ (no network binding) cannot use MCP — only ntok_/atok_/global token
235
- if (authCtx && !authCtx.networkId) {
236
- return withCors(req, Response.json({
237
- jsonrpc: "2.0",
238
- error: { code: -32000, message: "User token (utok_) cannot access MCP. Use a network token (ntok_) instead." },
239
- id: null,
240
- }, { status: 403 }));
241
- }
242
245
  const transport = new WebStandardStreamableHTTPServerTransport({
243
246
  sessionIdGenerator: undefined,
244
247
  });
@@ -599,7 +602,7 @@ Bun.serve({
599
602
  const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
600
603
  return withCors(req, Response.json({
601
604
  ok: true,
602
- version: "0.5.0-preview.33",
605
+ version: SERVER_VERSION,
603
606
  api_version: "v3",
604
607
  transport: "streamable-http",
605
608
  sessions_count: count?.cnt ?? 0,
@@ -918,7 +921,7 @@ Bun.serve({
918
921
  }
919
922
 
920
923
  return withCors(req, new Response(
921
- `CommHub MCP Server v0.5.0-preview.33 (Streamable HTTP + SSE Push)
924
+ `CommHub MCP Server v${SERVER_VERSION} (Streamable HTTP + SSE Push)
922
925
 
923
926
  Endpoints:
924
927
  POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
@@ -1030,7 +1033,7 @@ process.on("SIGINT", shutdown);
1030
1033
 
1031
1034
  console.log(`
1032
1035
  ╔══════════════════════════════════════════════════╗
1033
- ║ CommHub MCP Server v0.5.0-preview.33
1036
+ ║ CommHub MCP Server v${SERVER_VERSION}
1034
1037
  ║ Transport: Streamable HTTP (Bun native) ║
1035
1038
  ║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
1036
1039
  ║ ║
package/src/tools.ts CHANGED
@@ -12,13 +12,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
12
12
  // If enforceNetworkId is set, override any client-supplied network_id
13
13
  const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
14
14
 
15
- // Check if the user has write access to the enforced network
16
- // utok_ (no networkId) cannot do MCP writes only ntok_/atok_ with network binding can
17
- const canWrite = (): boolean => {
15
+ // Check write access. For ntok_ the network is enforced by the token.
16
+ // For utok_ (no enforced network) we accept the network_id supplied in the
17
+ // request and verify the user has a write role on it.
18
+ const canWrite = (effectiveNetworkId?: string | null): boolean => {
18
19
  if (!enforceUserId) return true; // legacy global token mode, allow
19
- if (!enforceNetworkId) return false; // utok_ has no network → cannot write MCP
20
- const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
21
- return !!role && role !== "viewer"; // owner/admin/member can write, viewer cannot
20
+ const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
21
+ if (!netId) return false; // no network resolvable
22
+ const role = getUserNetworkRole(enforceUserId, netId);
23
+ return !!role && role !== "viewer"; // owner/admin/member can write
22
24
  };
23
25
 
24
26
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
@@ -66,7 +68,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
66
68
  },
67
69
  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 }) => {
68
70
  const effectiveNetId = getNetworkId(netId);
69
- if (!canWrite()) {
71
+ if (!canWrite(effectiveNetId)) {
70
72
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
71
73
  }
72
74
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
@@ -177,7 +179,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
177
179
  },
178
180
  async ({ alias, task, result, artifacts, score, duration_minutes, network_id: netId }) => {
179
181
  const effectiveNetId = getNetworkId(netId);
180
- if (!canWrite()) {
182
+ if (!canWrite(effectiveNetId)) {
181
183
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
182
184
  }
183
185
  console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}${effectiveNetId ? " [net]" : ""}`);
@@ -267,7 +269,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
267
269
  },
268
270
  async ({ alias, message_id, response }) => {
269
271
  const effectiveNetId = getNetworkId(null);
270
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
272
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
271
273
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
272
274
  const ackParams: any[] = [message_id, alias];
273
275
  let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
@@ -391,7 +393,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
391
393
  const effectiveNetId = getNetworkId(netId);
392
394
 
393
395
  // Role check: viewer cannot send tasks
394
- if (!canWrite()) {
396
+ if (!canWrite(effectiveNetId)) {
395
397
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
396
398
  }
397
399
 
@@ -458,7 +460,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
458
460
  },
459
461
  async ({ alias, message, from_session }) => {
460
462
  const effectiveNetId = getNetworkId(null);
461
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
463
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
462
464
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
463
465
  const id = uuidv4();
464
466
  db.run(
@@ -499,7 +501,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
499
501
  },
500
502
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
501
503
  const effectiveNetId = getNetworkId(null);
502
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
504
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
503
505
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
504
506
  const id = uuidv4();
505
507
  const replyLogged = db.transaction(() => {
@@ -550,7 +552,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
550
552
  },
551
553
  async ({ task_id, from_session }) => {
552
554
  const effectiveNetId = getNetworkId(null);
553
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
555
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
554
556
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
555
557
  const updateParams: any[] = [task_id];
556
558
  let updateSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')";
@@ -576,7 +578,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
576
578
  },
577
579
  async ({ task_id, from_session }) => {
578
580
  const effectiveNetId = getNetworkId(null);
579
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
581
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
580
582
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
581
583
  // Find the original task
582
584
  const taskParams: any[] = [task_id];
@@ -687,7 +689,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
687
689
  },
688
690
  async ({ task_id, reason, from_session }) => {
689
691
  const effectiveNetId = getNetworkId(null);
690
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
692
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
691
693
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
692
694
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
693
695
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
@@ -719,7 +721,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
719
721
  },
720
722
  async ({ task_id, new_alias, from_session }) => {
721
723
  const effectiveNetId = getNetworkId(null);
722
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
724
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
723
725
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
724
726
  const taskParams: any[] = [task_id];
725
727
  let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
@@ -765,7 +767,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
765
767
  },
766
768
  async ({ message, filter_server, filter_status, network_id: netId }) => {
767
769
  const effectiveNetId = getNetworkId(netId);
768
- if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
770
+ if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
769
771
  console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
770
772
  let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
771
773
  const params: any[] = [];