@sleep2agi/commhub-server 0.5.0-preview.13 → 0.5.0-preview.15
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/index.ts +54 -19
- package/src/tools.ts +27 -10
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -10,26 +10,45 @@ const PORT = Number(process.env.PORT) || 9200;
|
|
|
10
10
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
11
11
|
|
|
12
12
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
13
|
-
function createServer(clientIP?: string): McpServer {
|
|
13
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
|
|
14
14
|
const server = new McpServer({
|
|
15
15
|
name: "commhub",
|
|
16
|
-
version: "0.
|
|
16
|
+
version: "0.5.0",
|
|
17
17
|
});
|
|
18
|
-
registerTools(server, clientIP);
|
|
18
|
+
registerTools(server, clientIP, enforceNetworkId);
|
|
19
19
|
return server;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// ── Auth helper ─────────────────────────────────────
|
|
23
23
|
function requireAuth(req: Request): Response | null {
|
|
24
|
-
|
|
25
|
-
const header = req.headers.get("Authorization");
|
|
26
|
-
if (header === `Bearer ${AUTH_TOKEN}`) return null;
|
|
27
|
-
// Also check query param for MCP clients that can't set headers
|
|
24
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
28
25
|
const url = new URL(req.url);
|
|
29
|
-
|
|
26
|
+
const token = header || url.searchParams.get("token") || "";
|
|
27
|
+
|
|
28
|
+
// V3: check api_tokens first
|
|
29
|
+
if (token) {
|
|
30
|
+
const resolved = resolveToken(token);
|
|
31
|
+
if (resolved) return null; // valid user token
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Legacy: check global COMMHUB_AUTH_TOKEN
|
|
35
|
+
if (!AUTH_TOKEN) return null; // no token = open mode (dev)
|
|
36
|
+
if (token === AUTH_TOKEN) return null;
|
|
37
|
+
|
|
30
38
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
31
39
|
}
|
|
32
40
|
|
|
41
|
+
// Extract user + network from request token (for authorization)
|
|
42
|
+
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
|
|
43
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
44
|
+
const url = new URL(req.url);
|
|
45
|
+
const token = header || url.searchParams.get("token") || "";
|
|
46
|
+
if (!token) return null;
|
|
47
|
+
const resolved = resolveToken(token);
|
|
48
|
+
if (!resolved) return null;
|
|
49
|
+
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
// ── REST input schema ───────────────────────────────
|
|
34
53
|
const TaskSchema = z.object({
|
|
35
54
|
alias: z.string().min(1).max(200),
|
|
@@ -120,10 +139,13 @@ Bun.serve({
|
|
|
120
139
|
if (authErr) return withCors(req, authErr);
|
|
121
140
|
const fwd = req.headers.get("x-forwarded-for");
|
|
122
141
|
const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
142
|
+
// V3: resolve token → enforce network_id in all MCP tools
|
|
143
|
+
const authCtx = resolveRequestAuth(req);
|
|
144
|
+
const enforceNetId = authCtx?.networkId || null;
|
|
123
145
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
124
146
|
sessionIdGenerator: undefined,
|
|
125
147
|
});
|
|
126
|
-
const server = createServer(clientIP);
|
|
148
|
+
const server = createServer(clientIP, enforceNetId);
|
|
127
149
|
await server.connect(transport);
|
|
128
150
|
const response = await transport.handleRequest(req);
|
|
129
151
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -243,6 +265,10 @@ Bun.serve({
|
|
|
243
265
|
const networkId = netDetailMatch[1];
|
|
244
266
|
const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
|
|
245
267
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
268
|
+
// Ownership check: only owner or admin can view
|
|
269
|
+
if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
|
|
270
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
271
|
+
}
|
|
246
272
|
// Get network stats
|
|
247
273
|
const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
|
|
248
274
|
const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
|
|
@@ -406,14 +432,22 @@ Bun.serve({
|
|
|
406
432
|
// ── REST: stats summary ──
|
|
407
433
|
if (url.pathname === "/api/stats") {
|
|
408
434
|
const n = url.searchParams.get("network_id");
|
|
409
|
-
|
|
410
|
-
const taskStats =
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
435
|
+
// Parameterized queries to prevent SQL injection
|
|
436
|
+
const taskStats = n
|
|
437
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(n)
|
|
438
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
439
|
+
const sessionStats = n
|
|
440
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status").all(n)
|
|
441
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
|
|
442
|
+
const totalTasks = n
|
|
443
|
+
? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1").get(n)
|
|
444
|
+
: db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
|
|
445
|
+
const totalNodes = n
|
|
446
|
+
? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(n)
|
|
447
|
+
: db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
|
|
448
|
+
const recentTasks = n
|
|
449
|
+
? db.query<any, [string]>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5").all(n)
|
|
450
|
+
: db.query<any, []>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5").all();
|
|
417
451
|
return withCors(req, Response.json({
|
|
418
452
|
ok: true,
|
|
419
453
|
network_id: n || null,
|
|
@@ -493,8 +527,9 @@ Bun.serve({
|
|
|
493
527
|
params.push(limit);
|
|
494
528
|
|
|
495
529
|
const rows = db.query(sql).all(...params);
|
|
496
|
-
const
|
|
497
|
-
|
|
530
|
+
const stats = netFilter
|
|
531
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(netFilter)
|
|
532
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
498
533
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
499
534
|
}
|
|
500
535
|
|
package/src/tools.ts
CHANGED
|
@@ -7,7 +7,9 @@ function ts(): string {
|
|
|
7
7
|
return new Date().toTimeString().slice(0, 8);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function registerTools(server: McpServer, clientIP?: string) {
|
|
10
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
|
|
11
|
+
// If enforceNetworkId is set, override any client-supplied network_id
|
|
12
|
+
const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
|
|
11
13
|
// ═══════════════════════════════════════════
|
|
12
14
|
// Child Agent Tools (4)
|
|
13
15
|
// ═══════════════════════════════════════════
|
|
@@ -39,12 +41,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
39
41
|
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
40
42
|
},
|
|
41
43
|
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 }) => {
|
|
42
|
-
|
|
44
|
+
const effectiveNetId = getNetworkId(netId);
|
|
45
|
+
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
|
|
43
46
|
const trimmedOutput = output?.slice(0, 4000);
|
|
44
47
|
|
|
45
48
|
try {
|
|
46
49
|
db.run("BEGIN IMMEDIATE");
|
|
47
|
-
|
|
50
|
+
// Only delete same-alias sessions within the same network (prevent cross-network alias conflict)
|
|
51
|
+
if (effectiveNetId) {
|
|
52
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
|
|
53
|
+
} else {
|
|
54
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
|
|
55
|
+
}
|
|
48
56
|
db.run(
|
|
49
57
|
`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)
|
|
50
58
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
|
|
@@ -270,9 +278,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
270
278
|
{
|
|
271
279
|
filter_status: z.string().max(50).optional(),
|
|
272
280
|
filter_server: z.string().max(200).optional(),
|
|
281
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
273
282
|
},
|
|
274
|
-
async ({ filter_status, filter_server }) => {
|
|
275
|
-
|
|
283
|
+
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
284
|
+
const effectiveNetId = getNetworkId(netId);
|
|
285
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
|
|
276
286
|
|
|
277
287
|
const sessions = db.transaction(() => {
|
|
278
288
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
@@ -280,6 +290,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
280
290
|
|
|
281
291
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
282
292
|
const params: any[] = [];
|
|
293
|
+
if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
|
|
283
294
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
284
295
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
285
296
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -339,6 +350,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
339
350
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
340
351
|
},
|
|
341
352
|
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
353
|
+
const effectiveNetId = getNetworkId(netId);
|
|
342
354
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
343
355
|
const id = uuidv4();
|
|
344
356
|
// 事务:inbox + tasks 双写
|
|
@@ -347,12 +359,12 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
347
359
|
db.run(
|
|
348
360
|
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
349
361
|
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
350
|
-
[id, alias, priority, task, context ?? null, from_session,
|
|
362
|
+
[id, alias, priority, task, context ?? null, from_session, effectiveNetId]
|
|
351
363
|
);
|
|
352
364
|
db.run(
|
|
353
365
|
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
354
366
|
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
355
|
-
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`,
|
|
367
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
|
|
356
368
|
);
|
|
357
369
|
db.run("COMMIT");
|
|
358
370
|
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
@@ -576,11 +588,14 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
576
588
|
alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
|
|
577
589
|
status: z.string().max(50).optional().describe("Filter by status"),
|
|
578
590
|
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
591
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
579
592
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
580
593
|
},
|
|
581
|
-
async ({ alias, status, from_name, limit }) => {
|
|
594
|
+
async ({ alias, status, from_name, network_id: netId, limit }) => {
|
|
595
|
+
const effectiveNetId = getNetworkId(netId);
|
|
582
596
|
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
583
597
|
const params: any[] = [];
|
|
598
|
+
if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
|
|
584
599
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
585
600
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
586
601
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
@@ -672,11 +687,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
672
687
|
message: z.string().min(1).max(10000),
|
|
673
688
|
filter_server: z.string().max(200).optional(),
|
|
674
689
|
filter_status: z.string().max(50).optional(),
|
|
690
|
+
network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
|
|
675
691
|
},
|
|
676
|
-
async ({ message, filter_server, filter_status }) => {
|
|
677
|
-
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${
|
|
692
|
+
async ({ message, filter_server, filter_status, network_id: netId }) => {
|
|
693
|
+
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${netId ? " [net=" + netId.slice(0, 12) + "]" : ""}`);
|
|
678
694
|
let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
|
|
679
695
|
const params: any[] = [];
|
|
696
|
+
if (netId) { sql += " AND network_id = ?"; params.push(netId); }
|
|
680
697
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
681
698
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
682
699
|
|