@sleep2agi/commhub-server 0.5.0-preview.27 → 0.5.0-preview.28
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 +22 -0
- package/src/db-adapter.ts +1 -1
- package/src/index.ts +30 -9
- package/src/tools.ts +22 -2
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 (27 端点)
|
|
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.28",
|
|
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,11 @@ 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
|
+
}
|
|
206
228
|
const token = generateToken();
|
|
207
229
|
const tokenId = generateId("tok");
|
|
208
230
|
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/index.ts
CHANGED
|
@@ -33,12 +33,12 @@ setInterval(() => {
|
|
|
33
33
|
}, 300000);
|
|
34
34
|
|
|
35
35
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
36
|
-
function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
|
|
36
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null): McpServer {
|
|
37
37
|
const server = new McpServer({
|
|
38
38
|
name: "commhub",
|
|
39
39
|
version: "0.5.0",
|
|
40
40
|
});
|
|
41
|
-
registerTools(server, clientIP, enforceNetworkId);
|
|
41
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId);
|
|
42
42
|
return server;
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -164,10 +164,18 @@ Bun.serve({
|
|
|
164
164
|
// V3: resolve token → enforce network_id in all MCP tools
|
|
165
165
|
const authCtx = resolveRequestAuth(req);
|
|
166
166
|
const enforceNetId = authCtx?.networkId || null;
|
|
167
|
+
// utok_ (no network binding) cannot use MCP — only ntok_/atok_/global token
|
|
168
|
+
if (authCtx && !authCtx.networkId) {
|
|
169
|
+
return withCors(req, Response.json({
|
|
170
|
+
jsonrpc: "2.0",
|
|
171
|
+
error: { code: -32000, message: "User token (utok_) cannot access MCP. Use a network token (ntok_) instead." },
|
|
172
|
+
id: null,
|
|
173
|
+
}, { status: 403 }));
|
|
174
|
+
}
|
|
167
175
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
168
176
|
sessionIdGenerator: undefined,
|
|
169
177
|
});
|
|
170
|
-
const server = createServer(clientIP, enforceNetId);
|
|
178
|
+
const server = createServer(clientIP, enforceNetId, authCtx?.userId || null);
|
|
171
179
|
await server.connect(transport);
|
|
172
180
|
const response = await transport.handleRequest(req);
|
|
173
181
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -261,7 +269,7 @@ Bun.serve({
|
|
|
261
269
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
262
270
|
const resolved = resolveToken(token);
|
|
263
271
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
264
|
-
const networks =
|
|
272
|
+
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
265
273
|
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
|
|
266
274
|
}
|
|
267
275
|
|
|
@@ -363,7 +371,12 @@ Bun.serve({
|
|
|
363
371
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
364
372
|
const resolved = resolveToken(token);
|
|
365
373
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
366
|
-
// V3.13:
|
|
374
|
+
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
375
|
+
if (resolved.networkId) {
|
|
376
|
+
// ntok_ — only return the bound network
|
|
377
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
|
|
378
|
+
return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
|
|
379
|
+
}
|
|
367
380
|
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
368
381
|
return withCors(req, Response.json({ ok: true, networks }));
|
|
369
382
|
}
|
|
@@ -469,8 +482,9 @@ Bun.serve({
|
|
|
469
482
|
const networkId = netDetailMatch[1];
|
|
470
483
|
const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
471
484
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
472
|
-
//
|
|
473
|
-
|
|
485
|
+
// Membership check: must be a member or system admin
|
|
486
|
+
const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
|
|
487
|
+
if (!viewerRole && resolved.user.role !== "admin") {
|
|
474
488
|
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
475
489
|
}
|
|
476
490
|
// Get network stats
|
|
@@ -536,11 +550,18 @@ Bun.serve({
|
|
|
536
550
|
const authErr = requireAuth(req);
|
|
537
551
|
if (authErr) return withCors(req, authErr);
|
|
538
552
|
|
|
553
|
+
// Resolve network scope for REST queries — enforce isolation
|
|
554
|
+
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
555
|
+
const restAuth = resolveRequestAuth(req);
|
|
556
|
+
const isAdmin = restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin";
|
|
557
|
+
// ntok_ token has networkId forced; utok_ has null (uses query param or admin sees all)
|
|
558
|
+
const restNetId = restAuth?.networkId || url.searchParams.get("network_id") || null;
|
|
559
|
+
|
|
539
560
|
// ── REST: all sessions status ──
|
|
540
561
|
if (url.pathname === "/api/status") {
|
|
541
562
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
542
563
|
db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
|
|
543
|
-
const netFilter =
|
|
564
|
+
const netFilter = restNetId;
|
|
544
565
|
const sql = netFilter
|
|
545
566
|
? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
|
|
546
567
|
: "SELECT * FROM sessions ORDER BY updated_at DESC";
|
|
@@ -750,7 +771,7 @@ Bun.serve({
|
|
|
750
771
|
const status = url.searchParams.get("status");
|
|
751
772
|
const toName = url.searchParams.get("to_name");
|
|
752
773
|
const fromName = url.searchParams.get("from_name");
|
|
753
|
-
const netFilter = url.searchParams.get("network_id");
|
|
774
|
+
const netFilter = restNetId || url.searchParams.get("network_id"); // token-enforced takes priority
|
|
754
775
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
755
776
|
|
|
756
777
|
let sql = "SELECT * FROM tasks WHERE 1=1";
|
package/src/tools.ts
CHANGED
|
@@ -2,14 +2,24 @@ 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
|
+
};
|
|
13
23
|
// ═══════════════════════════════════════════
|
|
14
24
|
// Child Agent Tools (4)
|
|
15
25
|
// ═══════════════════════════════════════════
|
|
@@ -66,7 +76,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
66
76
|
session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
|
|
67
77
|
channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
|
|
68
78
|
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,
|
|
79
|
+
[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
80
|
);
|
|
71
81
|
});
|
|
72
82
|
|
|
@@ -325,6 +335,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
325
335
|
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
326
336
|
const effectiveNetId = getNetworkId(netId);
|
|
327
337
|
|
|
338
|
+
// Role check: viewer cannot send tasks
|
|
339
|
+
if (!canWrite()) {
|
|
340
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
|
|
341
|
+
}
|
|
342
|
+
|
|
328
343
|
// License check
|
|
329
344
|
const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
|
|
330
345
|
if (license?.expires_at) {
|
|
@@ -386,6 +401,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
386
401
|
from_session: z.string().max(200).optional().default("hub"),
|
|
387
402
|
},
|
|
388
403
|
async ({ alias, message, from_session }) => {
|
|
404
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
389
405
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
390
406
|
const id = uuidv4();
|
|
391
407
|
db.run(
|
|
@@ -425,6 +441,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
425
441
|
from_session: z.string().max(200).optional().default("hub"),
|
|
426
442
|
},
|
|
427
443
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
444
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
428
445
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
429
446
|
const id = uuidv4();
|
|
430
447
|
const replyLogged = db.transaction(() => {
|
|
@@ -498,6 +515,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
498
515
|
from_session: z.string().max(200).optional().default("hub"),
|
|
499
516
|
},
|
|
500
517
|
async ({ task_id, from_session }) => {
|
|
518
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
501
519
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
502
520
|
// Find the original task
|
|
503
521
|
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
|
|
@@ -595,6 +613,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
595
613
|
from_session: z.string().max(200).optional().default("hub"),
|
|
596
614
|
},
|
|
597
615
|
async ({ task_id, reason, from_session }) => {
|
|
616
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
598
617
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
599
618
|
const result = db.run(
|
|
600
619
|
`UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
@@ -622,6 +641,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
622
641
|
from_session: z.string().max(200).optional().default("hub"),
|
|
623
642
|
},
|
|
624
643
|
async ({ task_id, new_alias, from_session }) => {
|
|
644
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
625
645
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
626
646
|
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
|
|
627
647
|
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|