@sleep2agi/commhub-server 0.5.0-preview.27 → 0.5.0-preview.29
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/README.md +43 -8
- package/package.json +1 -1
- package/src/auth.ts +23 -0
- package/src/db-adapter.ts +1 -1
- package/src/db.ts +7 -2
- package/src/index.ts +201 -61
- package/src/tools.ts +242 -126
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
|
|
|
18
18
|
- REST: `http://0.0.0.0:9200/api/*` (Dashboard / 监控)
|
|
19
19
|
- Health: `http://0.0.0.0:9200/health`
|
|
20
20
|
|
|
21
|
-
## MCP 工具 (
|
|
21
|
+
## MCP 工具 (18 个)
|
|
22
22
|
|
|
23
23
|
### Agent 端 (从 Agent 调用)
|
|
24
24
|
| 工具 | 说明 |
|
|
@@ -43,22 +43,50 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
|
|
|
43
43
|
| `get_session_status` | 单 session 详情 |
|
|
44
44
|
| `broadcast` | 群发消息 |
|
|
45
45
|
|
|
46
|
-
## REST API
|
|
46
|
+
## REST API (33 端点)
|
|
47
47
|
|
|
48
48
|
| 端点 | 方法 | 说明 |
|
|
49
49
|
|------|------|------|
|
|
50
50
|
| `/health` | GET | 健康检查 (无需 auth) |
|
|
51
|
+
| `/mcp` | POST | MCP Streamable HTTP |
|
|
52
|
+
| **认证** | | |
|
|
53
|
+
| `/api/auth/register` | POST | 注册 → utok_ + ntok_ |
|
|
54
|
+
| `/api/auth/login` | POST | 登录 → utok_ |
|
|
55
|
+
| `/api/auth/me` | GET | 当前用户信息 |
|
|
56
|
+
| `/api/auth/me` | PUT | 修改资料 |
|
|
57
|
+
| `/api/auth/password` | POST | 修改密码 |
|
|
58
|
+
| `/api/auth/tokens` | GET | Token 列表 |
|
|
59
|
+
| `/api/auth/tokens` | POST | 创建 Token |
|
|
60
|
+
| `/api/auth/tokens/:id` | DELETE | 撤销 Token |
|
|
61
|
+
| `/api/auth/node-token` | POST | 创建节点网络 Token (ntok_) |
|
|
62
|
+
| **网络** | | |
|
|
63
|
+
| `/api/networks` | GET | 我的网络列表(成员网络) |
|
|
64
|
+
| `/api/networks` | POST | 创建网络 |
|
|
65
|
+
| `/api/networks/:id` | GET | 网络详情 + 统计 |
|
|
66
|
+
| `/api/networks/:id` | PUT | 重命名网络 |
|
|
67
|
+
| `/api/networks/:id` | DELETE | 删除网络 |
|
|
68
|
+
| `/api/networks/:id/members` | GET | 成员列表 |
|
|
69
|
+
| `/api/networks/:id/members` | POST | 添加成员 |
|
|
70
|
+
| `/api/networks/:id/members/:uid` | PUT | 修改成员角色 |
|
|
71
|
+
| `/api/networks/:id/members/:uid` | DELETE | 移除成员 |
|
|
72
|
+
| `/api/networks/:id/invite` | POST | 生成邀请码 |
|
|
73
|
+
| `/api/networks/join` | POST | 用邀请码加入 |
|
|
74
|
+
| **数据** | | |
|
|
51
75
|
| `/api/status` | GET | 所有 session |
|
|
52
|
-
| `/api/tasks` | GET | 任务列表
|
|
53
|
-
| `/api/nodes` | GET |
|
|
54
|
-
| `/api/
|
|
76
|
+
| `/api/tasks` | GET | 任务列表 |
|
|
77
|
+
| `/api/nodes` | GET | 节点信息 |
|
|
78
|
+
| `/api/stats` | GET | 统计汇总 |
|
|
55
79
|
| `/api/messages` | GET | 消息列表 |
|
|
56
80
|
| `/api/completions` | GET | 完成记录 |
|
|
57
|
-
| `/
|
|
81
|
+
| `/api/task_events` | GET | 任务审计日志 |
|
|
82
|
+
| `/api/audit-log` | GET | 操作审计日志 |
|
|
83
|
+
| `/api/users` | GET | 用户列表 (admin) |
|
|
84
|
+
| `/api/license` | GET | License 状态 |
|
|
85
|
+
| `/api/license/activate` | POST | 激活授权码 |
|
|
58
86
|
|
|
59
|
-
## 数据表 (
|
|
87
|
+
## 数据表 (13 表)
|
|
60
88
|
|
|
61
|
-
|
|
89
|
+
自动创建,SQLite
|
|
62
90
|
|
|
63
91
|
| 表 | 说明 |
|
|
64
92
|
|---|------|
|
|
@@ -68,6 +96,13 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
|
|
|
68
96
|
| `nodes` | 持久化节点身份 (11 列, 独立于 session) |
|
|
69
97
|
| `completions` | 完成记录 (7 列) |
|
|
70
98
|
| `task_events` | 审计日志 (7 列, 每次状态变化记录) |
|
|
99
|
+
| `users` | 用户 (username/password_hash/role/plan) |
|
|
100
|
+
| `networks` | 网络 (name/owner/visibility/max_members) |
|
|
101
|
+
| `api_tokens` | API Token (utok_/ntok_/atok_ + scope + network) |
|
|
102
|
+
| `audit_log` | 操作审计 (user/action/target/ip) |
|
|
103
|
+
| `licenses` | License (type/expires/limits) |
|
|
104
|
+
| `network_members` | 网络成员 (user ↔ network + role) |
|
|
105
|
+
| `network_invites` | 邀请码 (code/role/max_uses/expires) |
|
|
71
106
|
|
|
72
107
|
任务状态机:
|
|
73
108
|
```
|
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.29",
|
|
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
|
@@ -22,6 +22,7 @@ export interface AuthResult {
|
|
|
22
22
|
|
|
23
23
|
export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
|
|
24
24
|
if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
|
|
25
|
+
if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
|
|
25
26
|
if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
|
|
26
27
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
27
28
|
|
|
@@ -157,7 +158,23 @@ export function getUserNetworks(userId: string) {
|
|
|
157
158
|
userId);
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
// Quota limits by plan
|
|
162
|
+
const QUOTAS: Record<string, { max_networks_owned: number; max_networks_joined: number }> = {
|
|
163
|
+
free: { max_networks_owned: 2, max_networks_joined: 3 },
|
|
164
|
+
pro: { max_networks_owned: 10, max_networks_joined: 20 },
|
|
165
|
+
admin: { max_networks_owned: Infinity, max_networks_joined: Infinity },
|
|
166
|
+
};
|
|
167
|
+
|
|
160
168
|
export function createNetwork(userId: string, name: string, description?: string) {
|
|
169
|
+
// Quota check
|
|
170
|
+
const user = db.get<any>("SELECT plan, role FROM users WHERE user_id = ?1", userId);
|
|
171
|
+
const plan = user?.role === "admin" ? "admin" : (user?.plan || "free");
|
|
172
|
+
const quota = QUOTAS[plan] || QUOTAS.free;
|
|
173
|
+
const ownedCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM networks WHERE owner_id = ?1", userId);
|
|
174
|
+
if ((ownedCount?.cnt || 0) >= quota.max_networks_owned) {
|
|
175
|
+
return { ok: false, error: `quota exceeded: max ${quota.max_networks_owned} networks for ${plan} plan` };
|
|
176
|
+
}
|
|
177
|
+
|
|
161
178
|
const existing = db.get<any>(
|
|
162
179
|
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
|
|
163
180
|
userId, name);
|
|
@@ -203,6 +220,12 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
|
|
|
203
220
|
}
|
|
204
221
|
|
|
205
222
|
export function createToken(userId: string, name: string, networkId?: string): { ok: boolean; token?: string; token_id?: string; error?: string } {
|
|
223
|
+
// Security: verify user is a member of the target network
|
|
224
|
+
if (networkId) {
|
|
225
|
+
const role = getUserNetworkRole(userId, networkId);
|
|
226
|
+
if (!role) return { ok: false, error: "not a member of this network" };
|
|
227
|
+
if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
|
|
228
|
+
}
|
|
206
229
|
const token = generateToken();
|
|
207
230
|
const tokenId = generateId("tok");
|
|
208
231
|
db.run(
|
package/src/db-adapter.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
50
50
|
constructor(private readonly rawDb: Database) {}
|
|
51
51
|
|
|
52
52
|
run(sql: string, params?: any[]): QueryResult {
|
|
53
|
-
return this.rawDb.run(sql, params as any);
|
|
53
|
+
return params ? this.rawDb.run(sql, params as any) : this.rawDb.run(sql);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
get<T = any>(sql: string, ...params: any[]): T | null {
|
package/src/db.ts
CHANGED
|
@@ -46,6 +46,7 @@ db.exec(`
|
|
|
46
46
|
artifacts TEXT,
|
|
47
47
|
score REAL,
|
|
48
48
|
duration_minutes REAL,
|
|
49
|
+
network_id TEXT,
|
|
49
50
|
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
50
51
|
);
|
|
51
52
|
`);
|
|
@@ -296,12 +297,15 @@ try {
|
|
|
296
297
|
} catch {}
|
|
297
298
|
|
|
298
299
|
// ── V3: add network_id to existing tables ──
|
|
299
|
-
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
|
|
300
|
+
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "completions"]) {
|
|
300
301
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
301
302
|
}
|
|
302
303
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
|
|
303
304
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
|
|
304
305
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
|
|
306
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)"); } catch {}
|
|
307
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
|
|
308
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions(network_id)"); } catch {}
|
|
305
309
|
|
|
306
310
|
// Helpers
|
|
307
311
|
export function uuidv4(): string {
|
|
@@ -344,7 +348,8 @@ export function logAudit(userId: string | null, username: string | null, action:
|
|
|
344
348
|
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
345
349
|
try {
|
|
346
350
|
db.run(
|
|
347
|
-
|
|
351
|
+
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
352
|
+
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
348
353
|
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
349
354
|
);
|
|
350
355
|
} catch {}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.j
|
|
|
7
7
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
8
|
|
|
9
9
|
const PORT = Number(process.env.PORT) || 9200;
|
|
10
|
+
const HOST = process.env.HOST || "0.0.0.0";
|
|
10
11
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
11
12
|
|
|
12
13
|
// ── Rate limiter (in-memory, per IP) ──
|
|
@@ -33,12 +34,12 @@ setInterval(() => {
|
|
|
33
34
|
}, 300000);
|
|
34
35
|
|
|
35
36
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
36
|
-
function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
|
|
37
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null): McpServer {
|
|
37
38
|
const server = new McpServer({
|
|
38
39
|
name: "commhub",
|
|
39
40
|
version: "0.5.0",
|
|
40
41
|
});
|
|
41
|
-
registerTools(server, clientIP, enforceNetworkId);
|
|
42
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId);
|
|
42
43
|
return server;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -72,12 +73,77 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
72
73
|
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
type RestNetworkScope = {
|
|
77
|
+
networkId: string | null;
|
|
78
|
+
networkIds: string[] | null;
|
|
79
|
+
denied?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function getUserNetworkIds(userId: string): string[] {
|
|
83
|
+
return db.all<{ network_id: string }>(
|
|
84
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
85
|
+
userId
|
|
86
|
+
).map((row) => row.network_id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
|
|
90
|
+
const requested = url.searchParams.get("network_id");
|
|
91
|
+
|
|
92
|
+
// Legacy global token or open dev mode keeps the old global behavior.
|
|
93
|
+
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
94
|
+
|
|
95
|
+
// Network tokens are forcibly scoped to their bound network.
|
|
96
|
+
if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
|
|
97
|
+
|
|
98
|
+
// System admins may intentionally inspect all networks.
|
|
99
|
+
if (isAdmin) return { networkId: requested || null, networkIds: null };
|
|
100
|
+
|
|
101
|
+
if (requested) {
|
|
102
|
+
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
103
|
+
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
104
|
+
return { networkId: requested, networkIds: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
111
|
+
if (scope.networkId) {
|
|
112
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
113
|
+
params.push(scope.networkId);
|
|
114
|
+
} else if (scope.networkIds) {
|
|
115
|
+
if (scope.networkIds.length === 0) {
|
|
116
|
+
sql += " AND 1=0";
|
|
117
|
+
} else {
|
|
118
|
+
const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
|
|
119
|
+
sql += ` AND ${column} IN (${placeholders})`;
|
|
120
|
+
params.push(...scope.networkIds);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return sql;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
127
|
+
if (scope.networkId) return scope.networkId;
|
|
128
|
+
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
133
|
+
if (!authCtx) return true; // legacy global token or open dev mode
|
|
134
|
+
if (isAdmin) return true;
|
|
135
|
+
if (!networkId) return false;
|
|
136
|
+
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
137
|
+
return !!role && role !== "viewer";
|
|
138
|
+
}
|
|
139
|
+
|
|
75
140
|
// ── REST input schema ───────────────────────────────
|
|
76
141
|
const TaskSchema = z.object({
|
|
77
142
|
alias: z.string().min(1).max(200),
|
|
78
143
|
task: z.string().min(1).max(10000),
|
|
79
144
|
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
80
145
|
from: z.string().max(200).optional(),
|
|
146
|
+
network_id: z.string().max(200).optional(),
|
|
81
147
|
});
|
|
82
148
|
|
|
83
149
|
const BroadcastSchema = z.object({
|
|
@@ -137,6 +203,7 @@ setInterval(() => {
|
|
|
137
203
|
|
|
138
204
|
Bun.serve({
|
|
139
205
|
port: PORT,
|
|
206
|
+
hostname: HOST,
|
|
140
207
|
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
141
208
|
|
|
142
209
|
async fetch(req, server) {
|
|
@@ -164,10 +231,18 @@ Bun.serve({
|
|
|
164
231
|
// V3: resolve token → enforce network_id in all MCP tools
|
|
165
232
|
const authCtx = resolveRequestAuth(req);
|
|
166
233
|
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
|
+
}
|
|
167
242
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
168
243
|
sessionIdGenerator: undefined,
|
|
169
244
|
});
|
|
170
|
-
const server = createServer(clientIP, enforceNetId);
|
|
245
|
+
const server = createServer(clientIP, enforceNetId, authCtx?.userId || null);
|
|
171
246
|
await server.connect(transport);
|
|
172
247
|
const response = await transport.handleRequest(req);
|
|
173
248
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -261,7 +336,7 @@ Bun.serve({
|
|
|
261
336
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
262
337
|
const resolved = resolveToken(token);
|
|
263
338
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
264
|
-
const networks =
|
|
339
|
+
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
265
340
|
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
|
|
266
341
|
}
|
|
267
342
|
|
|
@@ -363,7 +438,12 @@ Bun.serve({
|
|
|
363
438
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
364
439
|
const resolved = resolveToken(token);
|
|
365
440
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
366
|
-
// V3.13:
|
|
441
|
+
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
442
|
+
if (resolved.networkId) {
|
|
443
|
+
// ntok_ — only return the bound network
|
|
444
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
|
|
445
|
+
return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
|
|
446
|
+
}
|
|
367
447
|
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
368
448
|
return withCors(req, Response.json({ ok: true, networks }));
|
|
369
449
|
}
|
|
@@ -469,8 +549,9 @@ Bun.serve({
|
|
|
469
549
|
const networkId = netDetailMatch[1];
|
|
470
550
|
const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
471
551
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
472
|
-
//
|
|
473
|
-
|
|
552
|
+
// Membership check: must be a member or system admin
|
|
553
|
+
const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
|
|
554
|
+
if (!viewerRole && resolved.user.role !== "admin") {
|
|
474
555
|
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
475
556
|
}
|
|
476
557
|
// Get network stats
|
|
@@ -536,15 +617,27 @@ Bun.serve({
|
|
|
536
617
|
const authErr = requireAuth(req);
|
|
537
618
|
if (authErr) return withCors(req, authErr);
|
|
538
619
|
|
|
620
|
+
// Resolve network scope for REST queries — enforce isolation
|
|
621
|
+
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
622
|
+
const restAuth = resolveRequestAuth(req);
|
|
623
|
+
const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
624
|
+
const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
|
|
625
|
+
if (restScope.denied) {
|
|
626
|
+
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
627
|
+
}
|
|
628
|
+
|
|
539
629
|
// ── REST: all sessions status ──
|
|
540
630
|
if (url.pathname === "/api/status") {
|
|
541
631
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
632
|
+
const staleParams: any[] = [cutoff];
|
|
633
|
+
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
634
|
+
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
635
|
+
db.run(staleSql, staleParams);
|
|
636
|
+
const params: any[] = [];
|
|
637
|
+
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
638
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
639
|
+
sql += " ORDER BY updated_at DESC";
|
|
640
|
+
const sessions = db.all(sql, ...params);
|
|
548
641
|
return withCors(req, Response.json({ ok: true, sessions }));
|
|
549
642
|
}
|
|
550
643
|
|
|
@@ -561,18 +654,40 @@ Bun.serve({
|
|
|
561
654
|
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
562
655
|
}
|
|
563
656
|
const body = parsed.data;
|
|
657
|
+
let taskNetId: string | null = null;
|
|
658
|
+
if (restAuth?.networkId) {
|
|
659
|
+
taskNetId = restAuth.networkId;
|
|
660
|
+
} else if (body.network_id) {
|
|
661
|
+
if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
|
|
662
|
+
return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
|
|
663
|
+
}
|
|
664
|
+
taskNetId = body.network_id;
|
|
665
|
+
} else {
|
|
666
|
+
taskNetId = restAuth ? singleNetworkId(restScope) : null;
|
|
667
|
+
}
|
|
668
|
+
if (restAuth && !taskNetId) {
|
|
669
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when multiple networks are available" }, { status: 400 }));
|
|
670
|
+
}
|
|
671
|
+
if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
672
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
673
|
+
}
|
|
564
674
|
const id = crypto.randomUUID();
|
|
565
675
|
const fromSession = body.from || "api";
|
|
566
676
|
db.run(
|
|
567
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
568
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
|
|
569
|
-
[id, body.alias, body.priority, body.task, fromSession]
|
|
677
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
678
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
|
|
679
|
+
[id, body.alias, body.priority, body.task, fromSession, taskNetId]
|
|
570
680
|
);
|
|
571
681
|
// SSE push: 秒达
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
682
|
+
const pendingParams: any[] = [body.alias];
|
|
683
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
684
|
+
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
685
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
686
|
+
const sessionParams: any[] = [body.alias];
|
|
687
|
+
let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
|
|
688
|
+
if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
|
|
689
|
+
const targetSession = db.get<any>(sessionSql, ...sessionParams);
|
|
690
|
+
if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
|
|
576
691
|
return withCors(req, Response.json({ ok: true, message_id: id }));
|
|
577
692
|
}
|
|
578
693
|
|
|
@@ -589,18 +704,25 @@ Bun.serve({
|
|
|
589
704
|
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
590
705
|
}
|
|
591
706
|
const body = parsed.data;
|
|
592
|
-
|
|
707
|
+
if (restAuth && !restScope.networkId && !isAdmin) {
|
|
708
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
|
|
709
|
+
}
|
|
710
|
+
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
711
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
712
|
+
}
|
|
713
|
+
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
593
714
|
const params: any[] = [];
|
|
715
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
594
716
|
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
595
717
|
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
596
|
-
const targets = db.all<{ alias: string }>(sql, ...params);
|
|
718
|
+
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
597
719
|
const ids: string[] = [];
|
|
598
720
|
for (const t of targets) {
|
|
599
721
|
const id = crypto.randomUUID();
|
|
600
722
|
db.run(
|
|
601
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
602
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
|
|
603
|
-
[id, t.alias, body.message]
|
|
723
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
724
|
+
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api', ?4)`,
|
|
725
|
+
[id, t.alias, body.message, t.network_id]
|
|
604
726
|
);
|
|
605
727
|
ids.push(id);
|
|
606
728
|
}
|
|
@@ -658,36 +780,49 @@ Bun.serve({
|
|
|
658
780
|
|
|
659
781
|
// ── REST: recent messages (for Dashboard communication graph) ──
|
|
660
782
|
if (url.pathname === "/api/messages") {
|
|
661
|
-
const limit = Number(url.searchParams.get("limit")) || 100;
|
|
783
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
662
784
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
785
|
+
const params: any[] = [since];
|
|
786
|
+
let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
|
|
787
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
788
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
789
|
+
params.push(limit);
|
|
790
|
+
const rows = db.all(sql, ...params);
|
|
666
791
|
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
667
792
|
}
|
|
668
793
|
|
|
669
794
|
// ── REST: stats summary ──
|
|
670
795
|
if (url.pathname === "/api/stats") {
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
796
|
+
const taskStatsParams: any[] = [];
|
|
797
|
+
let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
798
|
+
taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
|
|
799
|
+
taskStatsSql += " GROUP BY status";
|
|
800
|
+
const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
|
|
801
|
+
|
|
802
|
+
const sessionStatsParams: any[] = [];
|
|
803
|
+
let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
804
|
+
sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
|
|
805
|
+
sessionStatsSql += " GROUP BY status";
|
|
806
|
+
const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
|
|
807
|
+
|
|
808
|
+
const totalTasksParams: any[] = [];
|
|
809
|
+
let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
|
|
810
|
+
totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
|
|
811
|
+
const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
|
|
812
|
+
|
|
813
|
+
const totalNodesParams: any[] = [];
|
|
814
|
+
let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
|
|
815
|
+
totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
|
|
816
|
+
const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
|
|
817
|
+
|
|
818
|
+
const recentTasksParams: any[] = [];
|
|
819
|
+
let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
|
|
820
|
+
recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
|
|
821
|
+
recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
|
|
822
|
+
const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
|
|
688
823
|
return withCors(req, Response.json({
|
|
689
824
|
ok: true,
|
|
690
|
-
network_id:
|
|
825
|
+
network_id: restScope.networkId || null,
|
|
691
826
|
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
692
827
|
sessions: { by_status: sessionStats },
|
|
693
828
|
nodes: { total: totalNodes?.cnt || 0 },
|
|
@@ -720,10 +855,11 @@ Bun.serve({
|
|
|
720
855
|
if (url.pathname === "/api/task_events") {
|
|
721
856
|
const taskId = url.searchParams.get("task_id");
|
|
722
857
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
723
|
-
let sql = "SELECT * FROM task_events";
|
|
858
|
+
let sql = "SELECT * FROM task_events WHERE 1=1";
|
|
724
859
|
const params: any[] = [];
|
|
725
|
-
|
|
726
|
-
sql +=
|
|
860
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
861
|
+
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
862
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
727
863
|
params.push(limit);
|
|
728
864
|
const rows = db.all(sql, ...params);
|
|
729
865
|
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
@@ -733,10 +869,9 @@ Bun.serve({
|
|
|
733
869
|
if (url.pathname === "/api/nodes") {
|
|
734
870
|
const nodeId = url.searchParams.get("node_id");
|
|
735
871
|
const alias = url.searchParams.get("alias");
|
|
736
|
-
const netFilter = url.searchParams.get("network_id");
|
|
737
872
|
let sql = "SELECT * FROM nodes WHERE 1=1";
|
|
738
873
|
const params: any[] = [];
|
|
739
|
-
|
|
874
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
740
875
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
741
876
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
742
877
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -750,12 +885,11 @@ Bun.serve({
|
|
|
750
885
|
const status = url.searchParams.get("status");
|
|
751
886
|
const toName = url.searchParams.get("to_name");
|
|
752
887
|
const fromName = url.searchParams.get("from_name");
|
|
753
|
-
const netFilter = url.searchParams.get("network_id");
|
|
754
888
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
755
889
|
|
|
756
890
|
let sql = "SELECT * FROM tasks WHERE 1=1";
|
|
757
891
|
const params: any[] = [];
|
|
758
|
-
|
|
892
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
759
893
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
760
894
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
761
895
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
@@ -764,16 +898,22 @@ Bun.serve({
|
|
|
764
898
|
params.push(limit);
|
|
765
899
|
|
|
766
900
|
const rows = db.all(sql, ...params);
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
901
|
+
const statsParams: any[] = [];
|
|
902
|
+
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
903
|
+
statsSql = addNetworkScope(statsSql, statsParams, restScope);
|
|
904
|
+
statsSql += " GROUP BY status";
|
|
905
|
+
const stats = db.all<any>(statsSql, ...statsParams);
|
|
770
906
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
771
907
|
}
|
|
772
908
|
|
|
773
909
|
// ── REST: recent completions ──
|
|
774
910
|
if (url.pathname === "/api/completions") {
|
|
775
911
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
776
|
-
const
|
|
912
|
+
const params: any[] = [since];
|
|
913
|
+
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
914
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
915
|
+
sql += " ORDER BY completed_at DESC LIMIT 100";
|
|
916
|
+
const rows = db.all(sql, ...params);
|
|
777
917
|
return withCors(req, Response.json({ ok: true, completions: rows }));
|
|
778
918
|
}
|
|
779
919
|
|
|
@@ -894,8 +1034,8 @@ console.log(`
|
|
|
894
1034
|
║ Transport: Streamable HTTP (Bun native) ║
|
|
895
1035
|
║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
|
|
896
1036
|
║ ║
|
|
897
|
-
║ MCP: http
|
|
898
|
-
║ REST: http
|
|
899
|
-
║ Health: http
|
|
1037
|
+
║ MCP: http://${HOST}:${PORT}/mcp ║
|
|
1038
|
+
║ REST: http://${HOST}:${PORT}/api ║
|
|
1039
|
+
║ Health: http://${HOST}:${PORT}/health ║
|
|
900
1040
|
╚══════════════════════════════════════════════════╝
|
|
901
1041
|
`);
|
package/src/tools.ts
CHANGED
|
@@ -2,14 +2,38 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { db, uuidv4, logTaskEvent } from "./db.js";
|
|
4
4
|
import { pushEvent, pushBroadcast } from "./push.js";
|
|
5
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
5
6
|
|
|
6
7
|
function ts(): string {
|
|
7
8
|
return new Date().toTimeString().slice(0, 8);
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
|
|
11
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null) {
|
|
11
12
|
// If enforceNetworkId is set, override any client-supplied network_id
|
|
12
13
|
const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
|
|
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 => {
|
|
18
|
+
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
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
|
|
25
|
+
if (!networkId) return sql;
|
|
26
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
27
|
+
params.push(networkId);
|
|
28
|
+
return sql;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
32
|
+
const params: any[] = [alias];
|
|
33
|
+
let sql = "SELECT status FROM sessions WHERE alias = ?1";
|
|
34
|
+
sql = addScope(sql, params, networkId);
|
|
35
|
+
return db.get<any>(sql, ...params);
|
|
36
|
+
};
|
|
13
37
|
// ═══════════════════════════════════════════
|
|
14
38
|
// Child Agent Tools (4)
|
|
15
39
|
// ═══════════════════════════════════════════
|
|
@@ -42,6 +66,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
42
66
|
},
|
|
43
67
|
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 }) => {
|
|
44
68
|
const effectiveNetId = getNetworkId(netId);
|
|
69
|
+
if (!canWrite()) {
|
|
70
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
71
|
+
}
|
|
45
72
|
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
|
|
46
73
|
const trimmedOutput = output?.slice(0, 4000);
|
|
47
74
|
|
|
@@ -66,23 +93,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
66
93
|
session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
|
|
67
94
|
channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
|
|
68
95
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
69
|
-
[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,
|
|
96
|
+
[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]
|
|
70
97
|
);
|
|
71
98
|
});
|
|
72
99
|
|
|
73
100
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
74
101
|
if (status === "working" && task) {
|
|
75
102
|
try {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2
|
|
79
|
-
|
|
80
|
-
);
|
|
103
|
+
const runParams: any[] = [alias, task];
|
|
104
|
+
let runSql = `UPDATE tasks SET status = 'running', started_at = datetime('now')
|
|
105
|
+
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`;
|
|
106
|
+
runSql = addScope(runSql, runParams, effectiveNetId);
|
|
107
|
+
const runResult = db.run(runSql, runParams);
|
|
81
108
|
if (runResult.changes > 0) {
|
|
82
109
|
// Find task_id for logging
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
const findParams: any[] = [alias, task];
|
|
111
|
+
let findSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running'";
|
|
112
|
+
findSql = addScope(findSql, findParams, effectiveNetId);
|
|
113
|
+
findSql += " ORDER BY started_at DESC LIMIT 1";
|
|
114
|
+
const t = db.get<{ task_id: string }>(findSql, ...findParams);
|
|
86
115
|
if (t) logTaskEvent(t.task_id, null, "running", alias);
|
|
87
116
|
}
|
|
88
117
|
} catch {}
|
|
@@ -94,8 +123,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
94
123
|
// Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
|
|
95
124
|
const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
|
|
96
125
|
db.run(
|
|
97
|
-
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
|
|
98
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
|
|
126
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
|
|
127
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
|
|
99
128
|
ON CONFLICT(node_id) DO UPDATE SET
|
|
100
129
|
node_name = COALESCE(?2, nodes.node_name),
|
|
101
130
|
alias = COALESCE(?3, nodes.alias),
|
|
@@ -105,16 +134,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
105
134
|
channels = COALESCE(?7, nodes.channels),
|
|
106
135
|
server = COALESCE(?8, nodes.server),
|
|
107
136
|
hostname = COALESCE(?9, nodes.hostname),
|
|
137
|
+
network_id = COALESCE(?10, nodes.network_id),
|
|
108
138
|
updated_at = datetime('now')`,
|
|
109
|
-
[node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
|
|
139
|
+
[node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
|
|
110
140
|
);
|
|
111
141
|
} catch {}
|
|
112
142
|
}
|
|
113
143
|
|
|
114
144
|
// inbox uses alias for routing
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
145
|
+
const inboxParams: any[] = [alias];
|
|
146
|
+
let inboxSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
147
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
148
|
+
const row = db.get<{ cnt: number }>(inboxSql, ...inboxParams);
|
|
118
149
|
|
|
119
150
|
return {
|
|
120
151
|
content: [
|
|
@@ -142,43 +173,53 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
142
173
|
artifacts: z.array(z.string().max(2000)).max(50).optional().describe("Output URLs or file paths"),
|
|
143
174
|
score: z.number().min(0).max(10).optional(),
|
|
144
175
|
duration_minutes: z.number().min(0).optional(),
|
|
176
|
+
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
145
177
|
},
|
|
146
|
-
async ({ alias, task, result, artifacts, score, duration_minutes }) => {
|
|
147
|
-
|
|
178
|
+
async ({ alias, task, result, artifacts, score, duration_minutes, network_id: netId }) => {
|
|
179
|
+
const effectiveNetId = getNetworkId(netId);
|
|
180
|
+
if (!canWrite()) {
|
|
181
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
182
|
+
}
|
|
183
|
+
console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}${effectiveNetId ? " [net]" : ""}`);
|
|
148
184
|
const id = uuidv4();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
|
|
152
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
|
153
|
-
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
|
|
154
|
-
);
|
|
185
|
+
let updatedTaskId: string | null = null;
|
|
186
|
+
db.transaction(() => {
|
|
155
187
|
db.run(
|
|
156
|
-
`
|
|
157
|
-
|
|
158
|
-
[alias]
|
|
188
|
+
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes, network_id)
|
|
189
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
|
|
190
|
+
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null, effectiveNetId ?? null]
|
|
159
191
|
);
|
|
192
|
+
const sessionParams: any[] = [alias];
|
|
193
|
+
let sessionSql = `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
|
|
194
|
+
WHERE alias = ?1`;
|
|
195
|
+
sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
|
|
196
|
+
db.run(sessionSql, sessionParams);
|
|
197
|
+
|
|
160
198
|
// V2: sync tasks table — try by task_id first, then by content
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')
|
|
164
|
-
|
|
165
|
-
);
|
|
199
|
+
const taskParams: any[] = [result.slice(0, 4000), task];
|
|
200
|
+
let taskSql = `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
|
|
201
|
+
WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`;
|
|
202
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
203
|
+
const tu = db.run(taskSql, taskParams);
|
|
166
204
|
if (tu.changes === 0) {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
AND status IN ('delivered', 'acked', 'running')
|
|
170
|
-
|
|
205
|
+
const matchParams: any[] = [alias, task];
|
|
206
|
+
let matchSql = `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
|
|
207
|
+
AND status IN ('delivered', 'acked', 'running')`;
|
|
208
|
+
matchSql = addScope(matchSql, matchParams, effectiveNetId);
|
|
209
|
+
matchSql += " ORDER BY created_at DESC LIMIT 1";
|
|
210
|
+
const match = db.get<{ task_id: string }>(matchSql, ...matchParams);
|
|
171
211
|
if (match) {
|
|
172
|
-
|
|
173
|
-
|
|
212
|
+
const matchUpdateParams: any[] = [result.slice(0, 4000), match.task_id];
|
|
213
|
+
let matchUpdateSql = "UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now') WHERE task_id = ?2";
|
|
214
|
+
matchUpdateSql = addScope(matchUpdateSql, matchUpdateParams, effectiveNetId);
|
|
215
|
+
db.run(matchUpdateSql, matchUpdateParams);
|
|
216
|
+
updatedTaskId = match.task_id;
|
|
174
217
|
}
|
|
218
|
+
} else {
|
|
219
|
+
updatedTaskId = task;
|
|
175
220
|
}
|
|
176
|
-
return tu.changes;
|
|
177
221
|
});
|
|
178
222
|
// Log event after transaction
|
|
179
|
-
const updatedTaskId = taskUpdateChanges > 0 ? task : (db.get<{ task_id: string }>(
|
|
180
|
-
"SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1",
|
|
181
|
-
alias)?.task_id);
|
|
182
223
|
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
183
224
|
|
|
184
225
|
return {
|
|
@@ -195,16 +236,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
195
236
|
limit: z.number().min(1).max(100).optional().default(10),
|
|
196
237
|
},
|
|
197
238
|
async ({ alias, limit }) => {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
239
|
+
const effectiveNetId = getNetworkId(null);
|
|
240
|
+
const countParams: any[] = [alias];
|
|
241
|
+
let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
242
|
+
countSql = addScope(countSql, countParams, effectiveNetId);
|
|
243
|
+
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
201
244
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
245
|
+
const rowsParams: any[] = [alias];
|
|
246
|
+
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
|
|
247
|
+
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
248
|
+
rowsSql = addScope(rowsSql, rowsParams, effectiveNetId);
|
|
249
|
+
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
250
|
+
LIMIT ?${rowsParams.length + 1}`;
|
|
251
|
+
rowsParams.push(limit);
|
|
252
|
+
const rows = db.all(rowsSql, ...rowsParams);
|
|
208
253
|
|
|
209
254
|
return {
|
|
210
255
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
@@ -221,8 +266,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
221
266
|
response: z.string().max(10000).optional(),
|
|
222
267
|
},
|
|
223
268
|
async ({ alias, message_id, response }) => {
|
|
269
|
+
const effectiveNetId = getNetworkId(null);
|
|
270
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
224
271
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
225
|
-
const
|
|
272
|
+
const ackParams: any[] = [message_id, alias];
|
|
273
|
+
let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
|
|
274
|
+
ackSql = addScope(ackSql, ackParams, effectiveNetId);
|
|
275
|
+
const result = db.run(ackSql, ackParams);
|
|
226
276
|
if (result.changes === 0) {
|
|
227
277
|
return {
|
|
228
278
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
|
|
@@ -230,10 +280,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
230
280
|
}
|
|
231
281
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
232
282
|
try {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
);
|
|
283
|
+
const taskParams: any[] = [message_id];
|
|
284
|
+
let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
|
|
285
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
286
|
+
const ackResult = db.run(taskSql, taskParams);
|
|
237
287
|
if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
|
|
238
288
|
} catch {}
|
|
239
289
|
return {
|
|
@@ -260,7 +310,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
260
310
|
|
|
261
311
|
const sessions = db.transaction(() => {
|
|
262
312
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
263
|
-
|
|
313
|
+
const staleParams: any[] = [cutoff];
|
|
314
|
+
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
315
|
+
staleSql = addScope(staleSql, staleParams, effectiveNetId);
|
|
316
|
+
db.run(staleSql, staleParams);
|
|
264
317
|
|
|
265
318
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
266
319
|
const params: any[] = [];
|
|
@@ -271,8 +324,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
271
324
|
return db.all(sql, ...params);
|
|
272
325
|
});
|
|
273
326
|
|
|
274
|
-
const
|
|
275
|
-
|
|
327
|
+
const summaryParams: any[] = [];
|
|
328
|
+
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
329
|
+
summarySql = addScope(summarySql, summaryParams, effectiveNetId);
|
|
330
|
+
summarySql += " GROUP BY status";
|
|
331
|
+
const summary = db.all(summarySql, ...summaryParams);
|
|
276
332
|
|
|
277
333
|
return {
|
|
278
334
|
content: [
|
|
@@ -290,14 +346,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
290
346
|
"Get detailed status of a specific session by alias.",
|
|
291
347
|
{ alias: z.string().min(1).max(200).describe("Session alias") },
|
|
292
348
|
async ({ alias }) => {
|
|
349
|
+
const effectiveNetId = getNetworkId(null);
|
|
293
350
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
351
|
+
const sessionParams: any[] = [alias];
|
|
352
|
+
let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
|
|
353
|
+
sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
|
|
354
|
+
const session = db.get(sessionSql, ...sessionParams);
|
|
355
|
+
|
|
356
|
+
const pendingParams: any[] = [alias];
|
|
357
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
358
|
+
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
359
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
360
|
+
|
|
361
|
+
const recentParams: any[] = [alias];
|
|
362
|
+
let recentSql = "SELECT * FROM completions WHERE session_name = ?1";
|
|
363
|
+
recentSql = addScope(recentSql, recentParams, effectiveNetId);
|
|
364
|
+
recentSql += " ORDER BY completed_at DESC LIMIT 5";
|
|
365
|
+
const recent = db.all(recentSql, ...recentParams);
|
|
301
366
|
|
|
302
367
|
return {
|
|
303
368
|
content: [
|
|
@@ -325,6 +390,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
325
390
|
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
326
391
|
const effectiveNetId = getNetworkId(netId);
|
|
327
392
|
|
|
393
|
+
// Role check: viewer cannot send tasks
|
|
394
|
+
if (!canWrite()) {
|
|
395
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
|
|
396
|
+
}
|
|
397
|
+
|
|
328
398
|
// License check
|
|
329
399
|
const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
|
|
330
400
|
if (license?.expires_at) {
|
|
@@ -344,23 +414,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
344
414
|
db.run(
|
|
345
415
|
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
346
416
|
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
347
|
-
[id, alias, priority, task, context ?? null, from_session, effectiveNetId]
|
|
417
|
+
[id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
|
|
348
418
|
);
|
|
349
419
|
db.run(
|
|
350
420
|
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
351
421
|
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
352
|
-
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
|
|
422
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null]
|
|
353
423
|
);
|
|
354
424
|
});
|
|
355
425
|
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
356
426
|
|
|
357
|
-
const session =
|
|
427
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
358
428
|
|
|
359
429
|
// SSE push by alias
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
430
|
+
const pendingParams: any[] = [alias];
|
|
431
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
432
|
+
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
433
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
434
|
+
if (session) pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
|
|
364
435
|
|
|
365
436
|
return {
|
|
366
437
|
content: [
|
|
@@ -386,17 +457,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
386
457
|
from_session: z.string().max(200).optional().default("hub"),
|
|
387
458
|
},
|
|
388
459
|
async ({ alias, message, from_session }) => {
|
|
460
|
+
const effectiveNetId = getNetworkId(null);
|
|
461
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
389
462
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
390
463
|
const id = uuidv4();
|
|
391
464
|
db.run(
|
|
392
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
393
|
-
VALUES (?1, ?2, 'message', 'normal', ?3, ?4)`,
|
|
394
|
-
[id, alias, message, from_session]
|
|
465
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
466
|
+
VALUES (?1, ?2, 'message', 'normal', ?3, ?4, ?5)`,
|
|
467
|
+
[id, alias, message, from_session, effectiveNetId ?? null]
|
|
395
468
|
);
|
|
396
469
|
|
|
397
|
-
const session =
|
|
470
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
398
471
|
|
|
399
|
-
pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
472
|
+
if (session) pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
400
473
|
|
|
401
474
|
return {
|
|
402
475
|
content: [
|
|
@@ -425,22 +498,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
425
498
|
from_session: z.string().max(200).optional().default("hub"),
|
|
426
499
|
},
|
|
427
500
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
501
|
+
const effectiveNetId = getNetworkId(null);
|
|
502
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
428
503
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
429
504
|
const id = uuidv4();
|
|
430
505
|
const replyLogged = db.transaction(() => {
|
|
431
506
|
db.run(
|
|
432
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
|
|
433
|
-
VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
|
|
434
|
-
[id, alias, text, from_session, in_reply_to ?? null]
|
|
507
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
508
|
+
VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
|
|
509
|
+
[id, alias, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
|
|
435
510
|
);
|
|
436
511
|
|
|
437
512
|
// 更新 tasks 表
|
|
438
513
|
if (in_reply_to) {
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')
|
|
442
|
-
|
|
443
|
-
);
|
|
514
|
+
const updateParams: any[] = [replyStatus, text, in_reply_to];
|
|
515
|
+
let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
|
|
516
|
+
WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
517
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
518
|
+
const result = db.run(updateSql, updateParams);
|
|
444
519
|
if (result.changes === 0) {
|
|
445
520
|
console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
|
|
446
521
|
return false;
|
|
@@ -453,8 +528,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
453
528
|
// Log event after commit (outside transaction)
|
|
454
529
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
455
530
|
|
|
456
|
-
const session =
|
|
457
|
-
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
531
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
532
|
+
if (session) pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
458
533
|
|
|
459
534
|
return {
|
|
460
535
|
content: [{
|
|
@@ -474,11 +549,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
474
549
|
from_session: z.string().max(200).optional().default("hub"),
|
|
475
550
|
},
|
|
476
551
|
async ({ task_id, from_session }) => {
|
|
552
|
+
const effectiveNetId = getNetworkId(null);
|
|
553
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
477
554
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
555
|
+
const updateParams: any[] = [task_id];
|
|
556
|
+
let updateSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')";
|
|
557
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
558
|
+
const result = db.run(updateSql, updateParams);
|
|
482
559
|
if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
|
|
483
560
|
return {
|
|
484
561
|
content: [{
|
|
@@ -498,9 +575,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
498
575
|
from_session: z.string().max(200).optional().default("hub"),
|
|
499
576
|
},
|
|
500
577
|
async ({ task_id, from_session }) => {
|
|
578
|
+
const effectiveNetId = getNetworkId(null);
|
|
579
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
501
580
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
502
581
|
// Find the original task
|
|
503
|
-
const
|
|
582
|
+
const taskParams: any[] = [task_id];
|
|
583
|
+
let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
584
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
585
|
+
const task = db.get<any>(taskSql, ...taskParams);
|
|
504
586
|
if (!task) {
|
|
505
587
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
506
588
|
}
|
|
@@ -509,22 +591,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
509
591
|
}
|
|
510
592
|
db.transaction(() => {
|
|
511
593
|
// Reset task status
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
WHERE task_id = ?1
|
|
515
|
-
|
|
516
|
-
);
|
|
594
|
+
const updateParams: any[] = [task_id];
|
|
595
|
+
let updateSql = `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
|
|
596
|
+
WHERE task_id = ?1`;
|
|
597
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
598
|
+
db.run(updateSql, updateParams);
|
|
517
599
|
// Re-queue in inbox with new ID (original ID may already exist)
|
|
518
600
|
const retryInboxId = uuidv4();
|
|
519
601
|
db.run(
|
|
520
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
|
|
521
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
|
|
522
|
-
[retryInboxId, task.to_name, task.priority, task.content, from_session]
|
|
602
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
|
|
603
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
|
|
604
|
+
[retryInboxId, task.to_name, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
|
|
523
605
|
);
|
|
524
606
|
});
|
|
525
607
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
526
608
|
// SSE push
|
|
527
|
-
|
|
609
|
+
if (scopedSessionStatus(task.to_name, effectiveNetId ?? task.network_id)) {
|
|
610
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
611
|
+
}
|
|
528
612
|
return {
|
|
529
613
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
530
614
|
};
|
|
@@ -539,7 +623,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
539
623
|
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
540
624
|
},
|
|
541
625
|
async ({ task_id }) => {
|
|
542
|
-
const
|
|
626
|
+
const effectiveNetId = getNetworkId(null);
|
|
627
|
+
const params: any[] = [task_id];
|
|
628
|
+
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
629
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
630
|
+
const task = db.get<any>(sql, ...params);
|
|
543
631
|
return {
|
|
544
632
|
content: [{
|
|
545
633
|
type: "text" as const,
|
|
@@ -573,8 +661,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
573
661
|
const tasks = db.all(sql, ...params);
|
|
574
662
|
|
|
575
663
|
// Stats
|
|
576
|
-
const
|
|
577
|
-
|
|
664
|
+
const statsParams: any[] = [];
|
|
665
|
+
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
666
|
+
statsSql = addScope(statsSql, statsParams, effectiveNetId);
|
|
667
|
+
statsSql += " GROUP BY status";
|
|
668
|
+
const stats = db.all(statsSql, ...statsParams);
|
|
578
669
|
|
|
579
670
|
return {
|
|
580
671
|
content: [{
|
|
@@ -595,15 +686,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
595
686
|
from_session: z.string().max(200).optional().default("hub"),
|
|
596
687
|
},
|
|
597
688
|
async ({ task_id, reason, from_session }) => {
|
|
689
|
+
const effectiveNetId = getNetworkId(null);
|
|
690
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
598
691
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')
|
|
602
|
-
|
|
603
|
-
);
|
|
692
|
+
const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
|
|
693
|
+
let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
694
|
+
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
695
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
696
|
+
const result = db.run(updateSql, updateParams);
|
|
604
697
|
// Also ack the inbox entry to prevent agent from picking it up
|
|
605
698
|
if (result.changes > 0) {
|
|
606
|
-
|
|
699
|
+
const inboxParams: any[] = [task_id];
|
|
700
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
701
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
702
|
+
db.run(inboxSql, inboxParams);
|
|
607
703
|
logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
608
704
|
}
|
|
609
705
|
return {
|
|
@@ -622,8 +718,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
622
718
|
from_session: z.string().max(200).optional().default("hub"),
|
|
623
719
|
},
|
|
624
720
|
async ({ task_id, new_alias, from_session }) => {
|
|
721
|
+
const effectiveNetId = getNetworkId(null);
|
|
722
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
625
723
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
626
|
-
const
|
|
724
|
+
const taskParams: any[] = [task_id];
|
|
725
|
+
let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
726
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
727
|
+
const task = db.get<any>(taskSql, ...taskParams);
|
|
627
728
|
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
628
729
|
if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
|
|
629
730
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
@@ -631,14 +732,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
631
732
|
const oldAlias = task.to_name;
|
|
632
733
|
db.transaction(() => {
|
|
633
734
|
// Ack old inbox to prevent original agent from picking it up
|
|
634
|
-
|
|
635
|
-
|
|
735
|
+
const inboxParams: any[] = [task_id];
|
|
736
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
737
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
738
|
+
db.run(inboxSql, inboxParams);
|
|
739
|
+
|
|
740
|
+
const updateParams: any[] = [new_alias, task_id];
|
|
741
|
+
let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2";
|
|
742
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
743
|
+
db.run(updateSql, updateParams);
|
|
744
|
+
|
|
636
745
|
const newInboxId = uuidv4();
|
|
637
|
-
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
|
|
638
|
-
[newInboxId, new_alias, task.priority, task.content, from_session]);
|
|
746
|
+
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)",
|
|
747
|
+
[newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
639
748
|
});
|
|
640
749
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
641
|
-
|
|
750
|
+
if (scopedSessionStatus(new_alias, effectiveNetId ?? task.network_id)) {
|
|
751
|
+
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
752
|
+
}
|
|
642
753
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
643
754
|
}
|
|
644
755
|
);
|
|
@@ -653,22 +764,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
653
764
|
network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
|
|
654
765
|
},
|
|
655
766
|
async ({ message, filter_server, filter_status, network_id: netId }) => {
|
|
656
|
-
|
|
657
|
-
|
|
767
|
+
const effectiveNetId = getNetworkId(netId);
|
|
768
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
769
|
+
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
|
|
770
|
+
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
658
771
|
const params: any[] = [];
|
|
659
|
-
|
|
772
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
660
773
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
661
774
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
662
775
|
|
|
663
|
-
const targets = db.all<{ alias: string }>(sql, ...params);
|
|
776
|
+
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
664
777
|
const ids: string[] = [];
|
|
665
778
|
|
|
666
779
|
for (const t of targets) {
|
|
667
780
|
const id = uuidv4();
|
|
668
781
|
db.run(
|
|
669
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
670
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub')`,
|
|
671
|
-
[id, t.alias, message]
|
|
782
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
783
|
+
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub', ?4)`,
|
|
784
|
+
[id, t.alias, message, effectiveNetId ?? t.network_id ?? null]
|
|
672
785
|
);
|
|
673
786
|
ids.push(id);
|
|
674
787
|
}
|
|
@@ -692,16 +805,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
692
805
|
{
|
|
693
806
|
since: z.string().optional().describe("ISO 8601 datetime, default last 24h"),
|
|
694
807
|
alias: z.string().max(200).optional().describe("Filter by session alias"),
|
|
808
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
695
809
|
limit: z.number().min(1).max(500).optional().default(50),
|
|
696
810
|
},
|
|
697
|
-
async ({ since, alias, limit }) => {
|
|
811
|
+
async ({ since, alias, network_id: netId, limit }) => {
|
|
812
|
+
const effectiveNetId = getNetworkId(netId);
|
|
698
813
|
console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
|
|
699
814
|
const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
700
815
|
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
701
816
|
const params: any[] = [cutoff];
|
|
817
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
702
818
|
|
|
703
819
|
if (alias) {
|
|
704
|
-
sql +=
|
|
820
|
+
sql += ` AND session_name = ?${params.length + 1}`;
|
|
705
821
|
params.push(alias);
|
|
706
822
|
}
|
|
707
823
|
|