@sleep2agi/commhub-server 0.5.0-preview.36 → 0.5.0-preview.38
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/auth.ts +8 -2
- package/src/index.ts +11 -6
- package/src/tools.ts +34 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.0-preview.
|
|
3
|
+
"version": "0.5.0-preview.38",
|
|
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/auth.ts
CHANGED
|
@@ -126,10 +126,11 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
|
|
|
126
126
|
return { ok: true, token };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
|
|
129
|
+
export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null } | null {
|
|
130
130
|
const tHash = hashToken(token);
|
|
131
131
|
const row = db.get<any>(
|
|
132
|
-
`SELECT t.user_id, t.network_id, t.scope,
|
|
132
|
+
`SELECT t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
133
|
+
u.username, u.display_name, u.email, u.role
|
|
133
134
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
134
135
|
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
|
|
135
136
|
tHash);
|
|
@@ -142,6 +143,11 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
142
143
|
return {
|
|
143
144
|
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
|
|
144
145
|
networkId: row.network_id,
|
|
146
|
+
// tokenName carries the binding identity. For node-scoped ntok_, it's
|
|
147
|
+
// 'node:<alias>'; we strip the prefix and use it as the default
|
|
148
|
+
// from_session for any MCP send_task / send_message / etc, so peer
|
|
149
|
+
// agents see who actually called them (not 'hub').
|
|
150
|
+
tokenName: row.token_name || null,
|
|
145
151
|
};
|
|
146
152
|
}
|
|
147
153
|
|
package/src/index.ts
CHANGED
|
@@ -42,12 +42,12 @@ setInterval(() => {
|
|
|
42
42
|
}, 300000);
|
|
43
43
|
|
|
44
44
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
45
|
-
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null): McpServer {
|
|
45
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null): McpServer {
|
|
46
46
|
const server = new McpServer({
|
|
47
47
|
name: "commhub",
|
|
48
48
|
version: "0.5.0",
|
|
49
49
|
});
|
|
50
|
-
registerTools(server, clientIP, enforceNetworkId, enforceUserId);
|
|
50
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias);
|
|
51
51
|
return server;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -70,15 +70,15 @@ function requireAuth(req: Request): Response | null {
|
|
|
70
70
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// Extract user + network from request token
|
|
74
|
-
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
|
|
73
|
+
// Extract user + network + token-binding identity from request token.
|
|
74
|
+
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null } | null {
|
|
75
75
|
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
76
76
|
const url = new URL(req.url);
|
|
77
77
|
const token = header || url.searchParams.get("token") || "";
|
|
78
78
|
if (!token) return null;
|
|
79
79
|
const resolved = resolveToken(token);
|
|
80
80
|
if (!resolved) return null;
|
|
81
|
-
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
|
|
81
|
+
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
type RestNetworkScope = {
|
|
@@ -242,10 +242,15 @@ Bun.serve({
|
|
|
242
242
|
// (which logs in as a user) cannot call send_task.
|
|
243
243
|
const authCtx = resolveRequestAuth(req);
|
|
244
244
|
const enforceNetId = authCtx?.networkId || null;
|
|
245
|
+
// Derive the calling alias from the token name (e.g., 'node:视频审查')
|
|
246
|
+
// so peer agents see the real sender instead of 'hub' on send_task.
|
|
247
|
+
const callerAlias = authCtx?.tokenName?.startsWith("node:")
|
|
248
|
+
? authCtx.tokenName.slice("node:".length)
|
|
249
|
+
: (authCtx?.username || null);
|
|
245
250
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
246
251
|
sessionIdGenerator: undefined,
|
|
247
252
|
});
|
|
248
|
-
const server = createServer(clientIP, enforceNetId, authCtx?.userId || null);
|
|
253
|
+
const server = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias);
|
|
249
254
|
await server.connect(transport);
|
|
250
255
|
const response = await transport.handleRequest(req);
|
|
251
256
|
// Disconnect after response to prevent McpServer leak
|
package/src/tools.ts
CHANGED
|
@@ -8,7 +8,13 @@ function ts(): string {
|
|
|
8
8
|
return new Date().toTimeString().slice(0, 8);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null) {
|
|
11
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null) {
|
|
12
|
+
// Default from_session for outbound tools — extracted from the calling
|
|
13
|
+
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
14
|
+
// an agent's send_task call always claimed from='hub' and peer agents
|
|
15
|
+
// couldn't tell who actually asked them. Tool callers can still override
|
|
16
|
+
// by passing from_session explicitly.
|
|
17
|
+
const defaultFrom = (clientFrom?: string) => clientFrom || callerAlias || "hub";
|
|
12
18
|
// If enforceNetworkId is set, override any client-supplied network_id
|
|
13
19
|
const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
|
|
14
20
|
|
|
@@ -385,11 +391,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
385
391
|
task: z.string().min(1).max(10000).describe("Task content"),
|
|
386
392
|
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
|
|
387
393
|
context: z.string().max(10000).optional(),
|
|
388
|
-
from_session: z.string().max(200).optional()
|
|
394
|
+
from_session: z.string().max(200).optional(),
|
|
389
395
|
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
390
396
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
391
397
|
},
|
|
392
|
-
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
398
|
+
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId }) => { const from_session = defaultFrom(_fromIn);
|
|
393
399
|
const effectiveNetId = getNetworkId(netId);
|
|
394
400
|
|
|
395
401
|
// Role check: viewer cannot send tasks
|
|
@@ -428,12 +434,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
428
434
|
|
|
429
435
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
430
436
|
|
|
431
|
-
// SSE push by alias
|
|
437
|
+
// SSE push by alias.
|
|
438
|
+
// The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
|
|
439
|
+
// not by network_id. Earlier we gated the push on a network-scoped session
|
|
440
|
+
// lookup, which silently dropped pushes whenever an agent registered with
|
|
441
|
+
// network_id=null but the sender supplied an explicit network_id (the
|
|
442
|
+
// exact mismatch hit by Dashboard tasks). Push unconditionally; the
|
|
443
|
+
// subscriber's own auth (ntok_) constrains who can listen.
|
|
432
444
|
const pendingParams: any[] = [alias];
|
|
433
445
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
434
446
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
435
447
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
436
|
-
|
|
448
|
+
pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
|
|
437
449
|
|
|
438
450
|
return {
|
|
439
451
|
content: [
|
|
@@ -456,9 +468,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
456
468
|
{
|
|
457
469
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
458
470
|
message: z.string().min(1).max(10000).describe("Message content"),
|
|
459
|
-
from_session: z.string().max(200).optional()
|
|
471
|
+
from_session: z.string().max(200).optional(),
|
|
460
472
|
},
|
|
461
|
-
async ({ alias, message, from_session }) => {
|
|
473
|
+
async ({ alias, message, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
|
|
462
474
|
const effectiveNetId = getNetworkId(null);
|
|
463
475
|
if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
464
476
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
@@ -471,7 +483,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
471
483
|
|
|
472
484
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
473
485
|
|
|
474
|
-
|
|
486
|
+
pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
475
487
|
|
|
476
488
|
return {
|
|
477
489
|
content: [
|
|
@@ -497,9 +509,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
497
509
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
498
510
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
499
511
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
500
|
-
from_session: z.string().max(200).optional()
|
|
512
|
+
from_session: z.string().max(200).optional(),
|
|
501
513
|
},
|
|
502
|
-
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
514
|
+
async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
|
|
503
515
|
const effectiveNetId = getNetworkId(null);
|
|
504
516
|
if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
505
517
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
@@ -531,7 +543,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
531
543
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
532
544
|
|
|
533
545
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
534
|
-
|
|
546
|
+
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
535
547
|
|
|
536
548
|
return {
|
|
537
549
|
content: [{
|
|
@@ -548,9 +560,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
548
560
|
"Acknowledge receipt of a task. Does NOT enter inbox. Updates task status only.",
|
|
549
561
|
{
|
|
550
562
|
task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
|
|
551
|
-
from_session: z.string().max(200).optional()
|
|
563
|
+
from_session: z.string().max(200).optional(),
|
|
552
564
|
},
|
|
553
|
-
async ({ task_id, from_session }) => {
|
|
565
|
+
async ({ task_id, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
|
|
554
566
|
const effectiveNetId = getNetworkId(null);
|
|
555
567
|
if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
556
568
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
@@ -574,9 +586,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
574
586
|
"Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
|
|
575
587
|
{
|
|
576
588
|
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
577
|
-
from_session: z.string().max(200).optional()
|
|
589
|
+
from_session: z.string().max(200).optional(),
|
|
578
590
|
},
|
|
579
|
-
async ({ task_id, from_session }) => {
|
|
591
|
+
async ({ task_id, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
|
|
580
592
|
const effectiveNetId = getNetworkId(null);
|
|
581
593
|
if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
582
594
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
@@ -607,10 +619,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
607
619
|
);
|
|
608
620
|
});
|
|
609
621
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
610
|
-
// SSE push
|
|
611
|
-
|
|
612
|
-
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
613
|
-
}
|
|
622
|
+
// SSE push (unconditional — channel is keyed by alias, not network)
|
|
623
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
614
624
|
return {
|
|
615
625
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
616
626
|
};
|
|
@@ -685,9 +695,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
685
695
|
{
|
|
686
696
|
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
687
697
|
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
688
|
-
from_session: z.string().max(200).optional()
|
|
698
|
+
from_session: z.string().max(200).optional(),
|
|
689
699
|
},
|
|
690
|
-
async ({ task_id, reason, from_session }) => {
|
|
700
|
+
async ({ task_id, reason, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
|
|
691
701
|
const effectiveNetId = getNetworkId(null);
|
|
692
702
|
if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
693
703
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
@@ -717,9 +727,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
717
727
|
{
|
|
718
728
|
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
719
729
|
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
720
|
-
from_session: z.string().max(200).optional()
|
|
730
|
+
from_session: z.string().max(200).optional(),
|
|
721
731
|
},
|
|
722
|
-
async ({ task_id, new_alias, from_session }) => {
|
|
732
|
+
async ({ task_id, new_alias, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
|
|
723
733
|
const effectiveNetId = getNetworkId(null);
|
|
724
734
|
if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
725
735
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
@@ -749,9 +759,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
749
759
|
[newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
750
760
|
});
|
|
751
761
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
752
|
-
|
|
753
|
-
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
754
|
-
}
|
|
762
|
+
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
755
763
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
756
764
|
}
|
|
757
765
|
);
|