@sleep2agi/commhub-server 0.5.0-preview.14 → 0.5.0-preview.16
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/db-adapter.ts +72 -0
- package/src/index.ts +54 -19
- package/src/tools.ts +19 -8
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Adapter Interface — async-first, supports SQLite and PostgreSQL
|
|
3
|
+
*
|
|
4
|
+
* SQLite adapter: wraps bun:sqlite sync calls in Promise (zero overhead)
|
|
5
|
+
* PostgreSQL adapter: uses bun:sql native async
|
|
6
|
+
*
|
|
7
|
+
* All callers use await — sync SQLite just resolves immediately.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface QueryResult {
|
|
11
|
+
changes: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DbAdapter {
|
|
15
|
+
/** Execute a write query (INSERT/UPDATE/DELETE) */
|
|
16
|
+
run(sql: string, params?: any[]): QueryResult;
|
|
17
|
+
|
|
18
|
+
/** Query a single row */
|
|
19
|
+
get<T = any>(sql: string, ...params: any[]): T | null;
|
|
20
|
+
|
|
21
|
+
/** Query multiple rows */
|
|
22
|
+
all<T = any>(sql: string, ...params: any[]): T[];
|
|
23
|
+
|
|
24
|
+
/** Execute raw SQL (DDL) */
|
|
25
|
+
exec(sql: string): void;
|
|
26
|
+
|
|
27
|
+
/** Run a function inside a transaction */
|
|
28
|
+
transaction<T>(fn: () => T): T;
|
|
29
|
+
|
|
30
|
+
/** Close connection */
|
|
31
|
+
close(): void;
|
|
32
|
+
|
|
33
|
+
/** Dialect identifier */
|
|
34
|
+
readonly dialect: 'sqlite' | 'postgres';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Phase 1 strategy:
|
|
39
|
+
*
|
|
40
|
+
* Current code is sync (bun:sqlite). We keep it sync for now.
|
|
41
|
+
* All DB access goes through the adapter interface above.
|
|
42
|
+
*
|
|
43
|
+
* When we add PostgreSQL (Phase 2), the adapter interface
|
|
44
|
+
* will change to async. At that point we'll update callers
|
|
45
|
+
* in a single pass. The unified call sites from Phase 1
|
|
46
|
+
* make that pass mechanical rather than archaeological.
|
|
47
|
+
*
|
|
48
|
+
* Why not async-first now?
|
|
49
|
+
* - bun:sqlite is sync, wrapping in Promise adds noise
|
|
50
|
+
* - All MCP tool handlers are already async, so the future
|
|
51
|
+
* migration is: db.run() → await db.run(), straightforward
|
|
52
|
+
* - 750+ lines of tools.ts would need gratuitous await for zero benefit today
|
|
53
|
+
*
|
|
54
|
+
* The contract: every DB call goes through adapter methods,
|
|
55
|
+
* never through raw db.query() or db.run() on the bun:sqlite object.
|
|
56
|
+
* This is what makes Phase 2 feasible.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** SQL helpers for cross-dialect compatibility */
|
|
60
|
+
export function sqlNow(dialect: 'sqlite' | 'postgres'): string {
|
|
61
|
+
return dialect === 'postgres' ? 'NOW()' : "datetime('now')";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sqlAddSeconds(dialect: 'sqlite' | 'postgres', seconds: number | string): string {
|
|
65
|
+
return dialect === 'postgres'
|
|
66
|
+
? `NOW() + INTERVAL '${seconds} seconds'`
|
|
67
|
+
: `datetime('now', '+${seconds} seconds')`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sqlPlaceholder(dialect: 'sqlite' | 'postgres', index: number): string {
|
|
71
|
+
return dialect === 'postgres' ? `$${index}` : `?${index}`;
|
|
72
|
+
}
|
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'))
|
|
@@ -273,7 +281,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
273
281
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
274
282
|
},
|
|
275
283
|
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
276
|
-
|
|
284
|
+
const effectiveNetId = getNetworkId(netId);
|
|
285
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
|
|
277
286
|
|
|
278
287
|
const sessions = db.transaction(() => {
|
|
279
288
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
@@ -281,7 +290,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
281
290
|
|
|
282
291
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
283
292
|
const params: any[] = [];
|
|
284
|
-
if (
|
|
293
|
+
if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
|
|
285
294
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
286
295
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
287
296
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -341,6 +350,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
341
350
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
342
351
|
},
|
|
343
352
|
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
353
|
+
const effectiveNetId = getNetworkId(netId);
|
|
344
354
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
345
355
|
const id = uuidv4();
|
|
346
356
|
// 事务:inbox + tasks 双写
|
|
@@ -349,12 +359,12 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
349
359
|
db.run(
|
|
350
360
|
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
351
361
|
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
352
|
-
[id, alias, priority, task, context ?? null, from_session,
|
|
362
|
+
[id, alias, priority, task, context ?? null, from_session, effectiveNetId]
|
|
353
363
|
);
|
|
354
364
|
db.run(
|
|
355
365
|
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
356
366
|
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
357
|
-
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`,
|
|
367
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
|
|
358
368
|
);
|
|
359
369
|
db.run("COMMIT");
|
|
360
370
|
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
@@ -582,9 +592,10 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
582
592
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
583
593
|
},
|
|
584
594
|
async ({ alias, status, from_name, network_id: netId, limit }) => {
|
|
595
|
+
const effectiveNetId = getNetworkId(netId);
|
|
585
596
|
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
586
597
|
const params: any[] = [];
|
|
587
|
-
if (
|
|
598
|
+
if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
|
|
588
599
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
589
600
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
590
601
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|