level-up-mcp-server-cn 0.4.0

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.
Files changed (137) hide show
  1. package/.claude/projects/c--Users-klexi-OneDrive-Desktop-Levelup-level-up-mcp-server/memory/project_testing_service.md +11 -0
  2. package/.claude/settings.local.json +10 -0
  3. package/.env.example +19 -0
  4. package/CLAUDE.md +222 -0
  5. package/CODE_REVIEW.md +282 -0
  6. package/LICENSE +64 -0
  7. package/README.md +198 -0
  8. package/dist/constants.d.ts +33 -0
  9. package/dist/constants.js +78 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/data/quest-seeds.d.ts +18 -0
  12. package/dist/data/quest-seeds.js +380 -0
  13. package/dist/data/quest-seeds.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +260 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/schemas/common.d.ts +33 -0
  18. package/dist/schemas/common.js +96 -0
  19. package/dist/schemas/common.js.map +1 -0
  20. package/dist/services/dispatcher.d.ts +27 -0
  21. package/dist/services/dispatcher.js +47 -0
  22. package/dist/services/dispatcher.js.map +1 -0
  23. package/dist/services/errors.d.ts +56 -0
  24. package/dist/services/errors.js +99 -0
  25. package/dist/services/errors.js.map +1 -0
  26. package/dist/services/format.d.ts +74 -0
  27. package/dist/services/format.js +144 -0
  28. package/dist/services/format.js.map +1 -0
  29. package/dist/services/ownership.d.ts +19 -0
  30. package/dist/services/ownership.js +79 -0
  31. package/dist/services/ownership.js.map +1 -0
  32. package/dist/services/quality-gate.d.ts +45 -0
  33. package/dist/services/quality-gate.js +131 -0
  34. package/dist/services/quality-gate.js.map +1 -0
  35. package/dist/services/rate-limit.d.ts +12 -0
  36. package/dist/services/rate-limit.js +49 -0
  37. package/dist/services/rate-limit.js.map +1 -0
  38. package/dist/services/register.d.ts +49 -0
  39. package/dist/services/register.js +63 -0
  40. package/dist/services/register.js.map +1 -0
  41. package/dist/services/supabase.d.ts +10 -0
  42. package/dist/services/supabase.js +79 -0
  43. package/dist/services/supabase.js.map +1 -0
  44. package/dist/tools/achievements.d.ts +6 -0
  45. package/dist/tools/achievements.js +242 -0
  46. package/dist/tools/achievements.js.map +1 -0
  47. package/dist/tools/admin.d.ts +16 -0
  48. package/dist/tools/admin.js +328 -0
  49. package/dist/tools/admin.js.map +1 -0
  50. package/dist/tools/agents.d.ts +3 -0
  51. package/dist/tools/agents.js +400 -0
  52. package/dist/tools/agents.js.map +1 -0
  53. package/dist/tools/bootstrap.d.ts +17 -0
  54. package/dist/tools/bootstrap.js +565 -0
  55. package/dist/tools/bootstrap.js.map +1 -0
  56. package/dist/tools/dispatchers/admin.d.ts +3 -0
  57. package/dist/tools/dispatchers/admin.js +50 -0
  58. package/dist/tools/dispatchers/admin.js.map +1 -0
  59. package/dist/tools/dispatchers/eval.d.ts +3 -0
  60. package/dist/tools/dispatchers/eval.js +40 -0
  61. package/dist/tools/dispatchers/eval.js.map +1 -0
  62. package/dist/tools/dispatchers/quests.d.ts +3 -0
  63. package/dist/tools/dispatchers/quests.js +60 -0
  64. package/dist/tools/dispatchers/quests.js.map +1 -0
  65. package/dist/tools/dispatchers/session.d.ts +3 -0
  66. package/dist/tools/dispatchers/session.js +38 -0
  67. package/dist/tools/dispatchers/session.js.map +1 -0
  68. package/dist/tools/dispatchers/skills.d.ts +3 -0
  69. package/dist/tools/dispatchers/skills.js +49 -0
  70. package/dist/tools/dispatchers/skills.js.map +1 -0
  71. package/dist/tools/dispatchers/tasks.d.ts +3 -0
  72. package/dist/tools/dispatchers/tasks.js +53 -0
  73. package/dist/tools/dispatchers/tasks.js.map +1 -0
  74. package/dist/tools/dispatchers/users.d.ts +3 -0
  75. package/dist/tools/dispatchers/users.js +65 -0
  76. package/dist/tools/dispatchers/users.js.map +1 -0
  77. package/dist/tools/dispatchers/xp.d.ts +3 -0
  78. package/dist/tools/dispatchers/xp.js +51 -0
  79. package/dist/tools/dispatchers/xp.js.map +1 -0
  80. package/dist/tools/growth-plan.d.ts +5 -0
  81. package/dist/tools/growth-plan.js +791 -0
  82. package/dist/tools/growth-plan.js.map +1 -0
  83. package/dist/tools/leaderboards.d.ts +10 -0
  84. package/dist/tools/leaderboards.js +279 -0
  85. package/dist/tools/leaderboards.js.map +1 -0
  86. package/dist/tools/leveling.d.ts +24 -0
  87. package/dist/tools/leveling.js +356 -0
  88. package/dist/tools/leveling.js.map +1 -0
  89. package/dist/tools/metrics.d.ts +3 -0
  90. package/dist/tools/metrics.js +247 -0
  91. package/dist/tools/metrics.js.map +1 -0
  92. package/dist/tools/quests.d.ts +5 -0
  93. package/dist/tools/quests.js +586 -0
  94. package/dist/tools/quests.js.map +1 -0
  95. package/dist/tools/ratings.d.ts +11 -0
  96. package/dist/tools/ratings.js +564 -0
  97. package/dist/tools/ratings.js.map +1 -0
  98. package/dist/tools/skills.d.ts +66 -0
  99. package/dist/tools/skills.js +1112 -0
  100. package/dist/tools/skills.js.map +1 -0
  101. package/dist/tools/system.d.ts +31 -0
  102. package/dist/tools/system.js +605 -0
  103. package/dist/tools/system.js.map +1 -0
  104. package/dist/tools/tasks.d.ts +73 -0
  105. package/dist/tools/tasks.js +1572 -0
  106. package/dist/tools/tasks.js.map +1 -0
  107. package/dist/tools/users.d.ts +97 -0
  108. package/dist/tools/users.js +1306 -0
  109. package/dist/tools/users.js.map +1 -0
  110. package/dist/tools/xp.d.ts +38 -0
  111. package/dist/tools/xp.js +670 -0
  112. package/dist/tools/xp.js.map +1 -0
  113. package/dist/types.d.ts +178 -0
  114. package/dist/types.js +12 -0
  115. package/dist/types.js.map +1 -0
  116. package/docs/recommended-skillsets.md +622 -0
  117. package/docs/skills-and-abilities-review.md +672 -0
  118. package/docs/v0.3-roadmap.md +191 -0
  119. package/package.json +35 -0
  120. package/sql/agent_pending_installs.sql +28 -0
  121. package/sql/award_class_xp.sql +81 -0
  122. package/supabase/.temp/cli-latest +1 -0
  123. package/supabase/.temp/gotrue-version +1 -0
  124. package/supabase/.temp/pooler-url +1 -0
  125. package/supabase/.temp/postgres-version +1 -0
  126. package/supabase/.temp/project-ref +1 -0
  127. package/supabase/.temp/rest-version +1 -0
  128. package/supabase/.temp/storage-migration +1 -0
  129. package/supabase/.temp/storage-version +1 -0
  130. package/supabase/migrations/20260314000000_anon_rls_policies.sql +311 -0
  131. package/supabase/migrations/20260314000001_ownership_rpcs.sql +382 -0
  132. package/supabase/migrations/20260314000002_evidence_and_growth_plan.sql +97 -0
  133. package/supabase/migrations/20260317000000_seed_quests.sql +62 -0
  134. package/supabase/migrations/20260317000001_star_cooldown_and_fixes.sql +16 -0
  135. package/supabase/migrations/20260318000000_restore_rank_names.sql +25 -0
  136. package/supabase/migrations/20260320000000_chinese_rank_names.sql +24 -0
  137. package/vitest.config.ts +11 -0
@@ -0,0 +1,1306 @@
1
+ // ============================================================
2
+ // tools/users.ts — Section 1: User tools (13 tools)
3
+ // ============================================================
4
+ // IMPORTANT — Column name mapping:
5
+ // The tool spec uses "identity_provider" / "identity_id" as
6
+ // PARAMETER names (what the LLM sends). But the actual database
7
+ // columns in user_identities are "provider" / "provider_id" /
8
+ // "is_hashed". We map between these in each handler.
9
+ // ============================================================
10
+ import { z } from "zod";
11
+ import { createHash, randomInt, timingSafeEqual } from "node:crypto";
12
+ import { supabase } from "../services/supabase.js";
13
+ import { ok, fail, handleSupabaseError } from "../services/errors.js";
14
+ import { generateProfileCode, generateLinkCode, formatRankDisplay } from "../services/format.js";
15
+ import { toMcpResponse, logRegistered } from "../services/register.js";
16
+ import { uuidSchema, identityProviderSchema, metadataSchema } from "../schemas/common.js";
17
+ function hashOtp(code) {
18
+ return createHash("sha256").update(code).digest("hex");
19
+ }
20
+ // ---- Helper: fetch public profile data for a user ----
21
+ // Several tools need to return a user's public profile.
22
+ // Instead of duplicating this query logic, we extract it here.
23
+ async function fetchPublicProfile(userId) {
24
+ const { data: user, error } = await supabase
25
+ .from("users")
26
+ .select("id, username, handle, profile_code, main_level, main_xp, is_searchable_by_email, email, created_at")
27
+ .eq("id", userId)
28
+ .single();
29
+ if (error || !user)
30
+ return null;
31
+ const { data: classes } = await supabase
32
+ .from("user_class_progress")
33
+ .select("class_name, class_level, class_xp")
34
+ .eq("user_id", userId);
35
+ const { data: rank } = await supabase
36
+ .from("rank_definitions")
37
+ .select("rank_name, rank_letter, min_level, max_level")
38
+ .eq("entity_type", "user")
39
+ .eq("locale", "zh")
40
+ .lte("min_level", user.main_level)
41
+ .gte("max_level", user.main_level)
42
+ .maybeSingle();
43
+ // BUG 4+5 FIX: Format rank display with stars
44
+ const rankDisplay = formatRankDisplay(user.main_level, rank?.rank_letter || null, rank?.rank_name || null, rank?.min_level || 1, rank?.max_level || 5);
45
+ const classMap = {};
46
+ for (const c of classes || []) {
47
+ classMap[c.class_name] = { level: c.class_level, xp: c.class_xp };
48
+ }
49
+ return {
50
+ id: user.id,
51
+ username: user.username,
52
+ handle: user.handle,
53
+ profile_code: user.profile_code,
54
+ rank_display: rankDisplay.rank_display,
55
+ rank_letter: rank?.rank_letter || null,
56
+ rank_name: rank?.rank_name || null,
57
+ stars: rankDisplay.stars,
58
+ stars_earned: rankDisplay.stars_earned,
59
+ stars_total: rankDisplay.stars_total,
60
+ main_xp: user.main_xp,
61
+ classes: classMap,
62
+ email: user.is_searchable_by_email ? user.email : null,
63
+ created_at: user.created_at,
64
+ };
65
+ }
66
+ // ============================================================
67
+ // Exported handler functions for dispatcher use
68
+ // ============================================================
69
+ export async function handleRegisterUser(params) {
70
+ const username = params.username;
71
+ const identity_provider = params.identity_provider;
72
+ const identity_id = params.identity_id;
73
+ const identity_is_hashed = params.identity_is_hashed ?? true;
74
+ const handle = params.handle;
75
+ const email = params.email;
76
+ const is_searchable_by_email = params.is_searchable_by_email ?? false;
77
+ const wallet_address = params.wallet_address;
78
+ const metadata = params.metadata;
79
+ const profileCode = generateProfileCode();
80
+ const { data: rpcResult, error: rpcError } = await supabase.rpc('register_user_secure', {
81
+ p_username: username,
82
+ p_email: email || null,
83
+ p_profile_code: profileCode,
84
+ p_handle: handle || null,
85
+ p_is_searchable_by_email: is_searchable_by_email,
86
+ p_wallet_address: wallet_address || null,
87
+ p_metadata: metadata || null,
88
+ p_identity_provider: identity_provider,
89
+ p_identity_id: identity_id,
90
+ p_identity_is_hashed: identity_is_hashed,
91
+ });
92
+ if (rpcError) {
93
+ console.error("[register_user] RPC failed, falling back to direct insert:", rpcError.message);
94
+ const { data: user, error: userError } = await supabase
95
+ .from("users")
96
+ .insert({
97
+ username,
98
+ email: email || null,
99
+ profile_code: profileCode,
100
+ handle: handle || null,
101
+ is_searchable_by_email,
102
+ wallet_address: wallet_address || null,
103
+ metadata: metadata || null,
104
+ })
105
+ .select()
106
+ .single();
107
+ if (userError)
108
+ return toMcpResponse(handleSupabaseError(userError, "register_user"));
109
+ const classRows = ["trainer", "orchestrator", "partner"].map((cls) => ({
110
+ user_id: user.id, class_name: cls, class_level: 1, class_xp: 0,
111
+ }));
112
+ const { error: classError } = await supabase.from("user_class_progress").insert(classRows);
113
+ if (classError)
114
+ console.error("[register_user] Class progress failed:", classError);
115
+ const { error: idError } = await supabase.from("user_identities").insert({
116
+ user_id: user.id,
117
+ provider: identity_provider,
118
+ provider_id: identity_id,
119
+ is_hashed: identity_is_hashed,
120
+ is_primary: true,
121
+ });
122
+ if (idError)
123
+ console.error("[register_user] Identity failed:", idError);
124
+ return toMcpResponse(ok({
125
+ id: user.id, username: user.username, profile_code: user.profile_code,
126
+ handle: user.handle, main_level: user.main_level, main_xp: user.main_xp,
127
+ classes: { trainer: { level: 1, xp: 0 }, orchestrator: { level: 1, xp: 0 }, partner: { level: 1, xp: 0 } },
128
+ created_at: user.created_at,
129
+ }));
130
+ }
131
+ return toMcpResponse(ok({
132
+ id: rpcResult.user_id, username, profile_code: profileCode,
133
+ handle: handle || null, main_level: 1, main_xp: 0,
134
+ classes: { trainer: { level: 1, xp: 0 }, orchestrator: { level: 1, xp: 0 }, partner: { level: 1, xp: 0 } },
135
+ created_at: rpcResult.created_at || new Date().toISOString(),
136
+ }));
137
+ }
138
+ export async function handleGetUserProfile(params) {
139
+ const user_id = params.user_id;
140
+ const profile = await fetchPublicProfile(user_id);
141
+ if (!profile)
142
+ return toMcpResponse(fail("未找到用户", "请检查 user_id 或使用 levelup_find_user 查找。"));
143
+ const { count: agentCount } = await supabase
144
+ .from("agents").select("id", { count: "exact", head: true })
145
+ .eq("owner_user_id", user_id).eq("status", "active");
146
+ const { count: achievementCount } = await supabase
147
+ .from("user_achievements").select("id", { count: "exact", head: true })
148
+ .eq("user_id", user_id);
149
+ return toMcpResponse(ok({ ...profile, agent_count: agentCount || 0, achievement_count: achievementCount || 0 }));
150
+ }
151
+ export async function handleGetUserProgress(params) {
152
+ const user_id = params.user_id;
153
+ const { data: user, error: userError } = await supabase
154
+ .from("users").select("id, username, main_level, main_xp").eq("id", user_id).single();
155
+ if (userError)
156
+ return toMcpResponse(handleSupabaseError(userError, "get_user_progress"));
157
+ const { data: classes } = await supabase
158
+ .from("user_class_progress").select("class_name, class_level, class_xp").eq("user_id", user_id);
159
+ const { data: curves } = await supabase
160
+ .from("level_curves").select("level, xp_required, cumulative_xp")
161
+ .eq("entity_type", "user").order("level", { ascending: true });
162
+ const { data: ranks } = await supabase
163
+ .from("rank_definitions").select("min_level, max_level, rank_name, rank_letter")
164
+ .eq("entity_type", "user").eq("locale", "zh").order("min_level", { ascending: true });
165
+ const computeProgress = (level, xp) => {
166
+ const nextCurve = (curves || []).find((c) => c.level === level + 1);
167
+ const xpForNext = nextCurve?.xp_required || 0;
168
+ const rank = (ranks || []).find((r) => level >= r.min_level && level <= r.max_level);
169
+ const progressPct = xpForNext > 0 ? Math.round((xp / xpForNext) * 100) : 100;
170
+ const rankInfo = formatRankDisplay(level, rank?.rank_letter || null, rank?.rank_name || null, rank?.min_level || 1, rank?.max_level || 5);
171
+ return {
172
+ current_xp: xp, xp_for_next_level: xpForNext,
173
+ xp_remaining: Math.max(0, xpForNext - xp), progress_pct: Math.min(100, progressPct),
174
+ rank_name: rank?.rank_name || null, rank_letter: rank?.rank_letter || null,
175
+ rank_display: rankInfo.rank_display, stars: rankInfo.stars,
176
+ stars_earned: rankInfo.stars_earned, stars_total: rankInfo.stars_total,
177
+ };
178
+ };
179
+ const tracks = {
180
+ main: computeProgress(user.main_level, user.main_xp),
181
+ };
182
+ for (const cls of classes || []) {
183
+ tracks[cls.class_name] = computeProgress(cls.class_level, cls.class_xp);
184
+ }
185
+ return toMcpResponse(ok({ user_id: user.id, username: user.username, tracks }));
186
+ }
187
+ export async function handleFindUser(params) {
188
+ const profile_code = params.profile_code;
189
+ const handle = params.handle;
190
+ const email = params.email;
191
+ if (!profile_code && !handle && !email) {
192
+ return toMcpResponse(fail("至少需要一个搜索参数", "请提供 profile_code、handle 或 email。"));
193
+ }
194
+ let userId = null;
195
+ if (profile_code) {
196
+ const { data } = await supabase.from("users").select("id").eq("profile_code", profile_code).maybeSingle();
197
+ userId = data?.id || null;
198
+ }
199
+ if (!userId && handle) {
200
+ const { data } = await supabase.from("users").select("id").eq("handle", handle).maybeSingle();
201
+ userId = data?.id || null;
202
+ }
203
+ if (!userId && email) {
204
+ const { data } = await supabase.from("users").select("id").eq("email", email).eq("is_searchable_by_email", true).maybeSingle();
205
+ userId = data?.id || null;
206
+ }
207
+ if (!userId)
208
+ return toMcpResponse(fail("未找到用户", "请检查搜索参数。邮箱搜索需要用户开启 is_searchable_by_email。"));
209
+ const profile = await fetchPublicProfile(userId);
210
+ return toMcpResponse(ok(profile));
211
+ }
212
+ export async function handleGenerateLinkCode(params) {
213
+ const user_id = params.user_id;
214
+ const expires_in_minutes = params.expires_in_minutes ?? 10;
215
+ const { error: userError } = await supabase.from("users").select("id").eq("id", user_id).single();
216
+ if (userError)
217
+ return toMcpResponse(fail("未找到用户", "请检查 user_id。"));
218
+ const code = generateLinkCode();
219
+ const expiresAt = new Date(Date.now() + expires_in_minutes * 60 * 1000).toISOString();
220
+ const { data: linkCode, error } = await supabase
221
+ .from("link_codes").insert({ user_id, code, expires_at: expiresAt, redeemed: false })
222
+ .select().single();
223
+ if (error)
224
+ return toMcpResponse(handleSupabaseError(error, "generate_link_code"));
225
+ return toMcpResponse(ok({ code: linkCode.code, expires_at: linkCode.expires_at, expires_in_minutes }));
226
+ }
227
+ export async function handleRedeemLinkCode(params) {
228
+ const code = params.code;
229
+ const identity_provider = params.identity_provider;
230
+ const identity_id = params.identity_id;
231
+ const identity_is_hashed = params.identity_is_hashed ?? true;
232
+ const { data: linkCode } = await supabase
233
+ .from("link_codes").select("id, user_id, expires_at, redeemed")
234
+ .eq("code", code).eq("redeemed", false).maybeSingle();
235
+ if (!linkCode)
236
+ return toMcpResponse(fail("链接码未找到或已使用", "请使用 levelup_generate_link_code 生成新的链接码。"));
237
+ if (new Date(linkCode.expires_at) < new Date())
238
+ return toMcpResponse(fail("链接码已过期", "请生成新的链接码。"));
239
+ const { error: idError } = await supabase.from("user_identities").insert({
240
+ user_id: linkCode.user_id, provider: identity_provider,
241
+ provider_id: identity_id, is_hashed: identity_is_hashed, is_primary: false,
242
+ });
243
+ if (idError)
244
+ return toMcpResponse(handleSupabaseError(idError, "redeem_link_code"));
245
+ await supabase.from("link_codes").update({ redeemed: true, redeemed_at: new Date().toISOString() }).eq("id", linkCode.id);
246
+ const profile = await fetchPublicProfile(linkCode.user_id);
247
+ return toMcpResponse(ok({ message: "身份绑定成功", linked_provider: identity_provider, profile }));
248
+ }
249
+ export async function handleLinkIdentity(params) {
250
+ const user_id = params.user_id;
251
+ const identity_provider = params.identity_provider;
252
+ const identity_id = params.identity_id;
253
+ const identity_is_hashed = params.identity_is_hashed ?? true;
254
+ const { error: userError } = await supabase.from("users").select("id").eq("id", user_id).single();
255
+ if (userError)
256
+ return toMcpResponse(fail("未找到用户", "请检查 user_id。"));
257
+ const { error: idError } = await supabase.from("user_identities").insert({
258
+ user_id, provider: identity_provider,
259
+ provider_id: identity_id, is_hashed: identity_is_hashed, is_primary: false,
260
+ });
261
+ if (idError)
262
+ return toMcpResponse(handleSupabaseError(idError, "link_identity"));
263
+ const { data: identities } = await supabase.from("user_identities").select("provider").eq("user_id", user_id);
264
+ return toMcpResponse(ok({
265
+ user_id,
266
+ linked_providers: (identities || []).map((i) => i.provider),
267
+ message: `${identity_provider} 绑定成功`,
268
+ }));
269
+ }
270
+ export async function handleRequestOtp(params) {
271
+ const channel = params.channel;
272
+ const destination = params.destination;
273
+ const otpCode = String(randomInt(100000, 999999));
274
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
275
+ await supabase.from("otp_codes").delete()
276
+ .eq("destination", destination).eq("channel", channel).eq("verified", false);
277
+ const { error } = await supabase.from("otp_codes").insert({
278
+ destination, channel,
279
+ code_hash: hashOtp(otpCode), attempts: 0, expires_at: expiresAt, verified: false,
280
+ });
281
+ if (error)
282
+ console.error("[request_otp] Insert failed:", error);
283
+ return toMcpResponse(ok({
284
+ message: "如果此邮箱有对应账户,验证码已发送。",
285
+ channel, expires_in_seconds: 300,
286
+ }));
287
+ }
288
+ export async function handleVerifyOtp(params) {
289
+ const channel = params.channel;
290
+ const destination = params.destination;
291
+ const code = params.code;
292
+ const { data: otp } = await supabase
293
+ .from("otp_codes").select("*")
294
+ .eq("destination", destination).eq("channel", channel)
295
+ .eq("verified", false).gt("expires_at", new Date().toISOString())
296
+ .order("created_at", { ascending: false }).limit(1).maybeSingle();
297
+ if (!otp)
298
+ return toMcpResponse(fail("未找到有效的验证码", "请使用 levelup_request_otp 重新获取验证码。"));
299
+ if (otp.attempts >= 3)
300
+ return toMcpResponse(fail("尝试次数过多,验证码已失效。", "请重新获取验证码。"));
301
+ const codeHashBuf = Buffer.from(otp.code_hash, "utf8");
302
+ const inputHashBuf = Buffer.from(hashOtp(code), "utf8");
303
+ const codeValid = codeHashBuf.length === inputHashBuf.length && timingSafeEqual(codeHashBuf, inputHashBuf);
304
+ if (!codeValid) {
305
+ const newAttempts = otp.attempts + 1;
306
+ await supabase.from("otp_codes").update({ attempts: newAttempts }).eq("id", otp.id);
307
+ return toMcpResponse(fail("验证码错误", `剩余 ${3 - newAttempts} 次尝试机会。`));
308
+ }
309
+ await supabase.from("otp_codes").update({ verified: true }).eq("id", otp.id);
310
+ const { data: identity } = await supabase
311
+ .from("user_identities").select("user_id")
312
+ .eq("provider", "email").eq("provider_id", destination).maybeSingle();
313
+ if (identity) {
314
+ const { data: user } = await supabase.from("users").select("id, profile_code").eq("id", identity.user_id).single();
315
+ return toMcpResponse(ok({ verified: true, user_id: user?.id, profile_code: user?.profile_code, is_new_user: false }));
316
+ }
317
+ return toMcpResponse(ok({
318
+ verified: true, user_id: null, profile_code: null, is_new_user: true,
319
+ message: "邮箱已验证,但未找到关联账户。请使用 levelup_register_user 创建账户。",
320
+ }));
321
+ }
322
+ export async function handleGetOnboardingStatus(params) {
323
+ const identity_provider = params.identity_provider;
324
+ const identity_id = params.identity_id;
325
+ const username = params.username;
326
+ let userId = null;
327
+ if (process.env.LEVELUP_USER_ID)
328
+ userId = process.env.LEVELUP_USER_ID;
329
+ if (!userId && identity_provider && identity_id) {
330
+ const { data: identity } = await supabase
331
+ .from("user_identities").select("user_id")
332
+ .eq("provider", identity_provider).eq("provider_id", identity_id).maybeSingle();
333
+ if (identity)
334
+ userId = identity.user_id;
335
+ }
336
+ if (!userId && username) {
337
+ const { data: userByName } = await supabase
338
+ .from("users").select("id").eq("username", username).maybeSingle();
339
+ if (userByName)
340
+ userId = userByName.id;
341
+ }
342
+ if (!userId) {
343
+ const { data: recentUser } = await supabase
344
+ .from("users").select("id").order("created_at", { ascending: false }).limit(1).maybeSingle();
345
+ if (recentUser)
346
+ userId = recentUser.id;
347
+ }
348
+ if (!userId) {
349
+ return toMcpResponse(ok({ is_registered: false, suggested_next_step: "请使用 levelup_register_user 注册账户" }));
350
+ }
351
+ const { data: user } = await supabase
352
+ .from("users").select("id, username, handle, email, main_xp, profile_code").eq("id", userId).single();
353
+ if (!user)
354
+ return toMcpResponse(ok({ is_registered: false, suggested_next_step: "请使用 levelup_register_user 注册账户" }));
355
+ const isPersonalized = user.username !== "Anonymous";
356
+ const { count: agentCount } = await supabase.from("agents").select("id", { count: "exact", head: true }).eq("owner_user_id", userId).eq("status", "active");
357
+ const { count: taskCount } = await supabase.from("tasks").select("id", { count: "exact", head: true }).eq("user_id", userId).eq("status", "completed");
358
+ const { count: questCount } = await supabase.from("quest_participants").select("id", { count: "exact", head: true }).eq("user_id", userId);
359
+ let suggestedNextStep = "一切就绪!可以开始执行任务了。";
360
+ if (!isPersonalized)
361
+ suggestedNextStep = "请使用 levelup_update_user_profile 个性化你的档案。";
362
+ else if (!user.handle)
363
+ suggestedNextStep = "请使用 levelup_update_user_profile 设置你的个人标识。";
364
+ else if ((agentCount || 0) === 0)
365
+ suggestedNextStep = "请使用 levelup_register_agent 注册你的第一个智能体。";
366
+ else if ((taskCount || 0) === 0)
367
+ suggestedNextStep = "请使用 levelup_start_task 完成你的第一个任务。";
368
+ return toMcpResponse(ok({
369
+ is_registered: true, user_id: userId, profile_code: user.profile_code,
370
+ is_personalized: isPersonalized, has_handle: !!user.handle, has_email: !!user.email,
371
+ has_agent: (agentCount || 0) > 0, has_completed_task: (taskCount || 0) > 0,
372
+ total_xp: user.main_xp, active_quests: questCount || 0, suggested_next_step: suggestedNextStep,
373
+ }));
374
+ }
375
+ export async function handleUpdateUserProfile(params) {
376
+ const user_id = params.user_id;
377
+ const updates = {};
378
+ if (params.username !== undefined)
379
+ updates.username = params.username;
380
+ if (params.handle !== undefined)
381
+ updates.handle = params.handle;
382
+ if (params.email !== undefined)
383
+ updates.email = params.email;
384
+ if (params.is_searchable_by_email !== undefined)
385
+ updates.is_searchable_by_email = params.is_searchable_by_email;
386
+ if (params.wallet_address !== undefined)
387
+ updates.wallet_address = params.wallet_address;
388
+ if (params.metadata !== undefined)
389
+ updates.metadata = params.metadata;
390
+ if (Object.keys(updates).length === 0) {
391
+ return toMcpResponse(fail("没有需要更新的字段", "请至少提供一个字段。"));
392
+ }
393
+ const { data: rpcResult, error } = await supabase.rpc('update_user_profile', {
394
+ p_user_id: user_id,
395
+ p_updates: updates,
396
+ });
397
+ if (error)
398
+ return toMcpResponse(handleSupabaseError(error, "update_user_profile"));
399
+ if (!rpcResult?.success)
400
+ return toMcpResponse(fail(rpcResult?.error || "更新失败"));
401
+ if (params.email) {
402
+ const { data: existing } = await supabase
403
+ .from("user_identities").select("id")
404
+ .eq("user_id", user_id).eq("provider", "email").maybeSingle();
405
+ if (existing) {
406
+ await supabase.from("user_identities").update({ provider_id: params.email, is_hashed: false }).eq("id", existing.id);
407
+ }
408
+ else {
409
+ await supabase.from("user_identities").insert({
410
+ user_id, provider: "email", provider_id: params.email, is_hashed: false, is_primary: false,
411
+ });
412
+ }
413
+ }
414
+ const profile = await fetchPublicProfile(user_id);
415
+ return toMcpResponse(ok(profile));
416
+ }
417
+ export async function handleInferTaskType(params) {
418
+ const description = params.description;
419
+ const tags = params.tags;
420
+ const { data: taskTypes } = await supabase
421
+ .from("task_types").select("id, name, description, base_xp, xp_tier, category").eq("status", "active");
422
+ if (!taskTypes || taskTypes.length === 0) {
423
+ return toMcpResponse(fail("未找到任务类型", "任务类型目录可能为空,请使用 levelup_suggest_task_type。"));
424
+ }
425
+ const descWords = description.toLowerCase().split(/\s+/);
426
+ const tagWords = (tags || []).map((t) => t.toLowerCase());
427
+ const allWords = [...descWords, ...tagWords];
428
+ let bestMatch = taskTypes[0];
429
+ let bestScore = 0;
430
+ for (const tt of taskTypes) {
431
+ const ttText = `${tt.name} ${tt.description || ""} ${tt.category || ""}`.toLowerCase();
432
+ let score = 0;
433
+ for (const word of allWords) {
434
+ if (word.length > 2 && ttText.includes(word))
435
+ score++;
436
+ }
437
+ if (score > bestScore) {
438
+ bestScore = score;
439
+ bestMatch = tt;
440
+ }
441
+ }
442
+ let suggestedDifficulty = 3;
443
+ let difficultySource = "default";
444
+ let tdiStats = null;
445
+ if (tags && tags.length > 0) {
446
+ const tagSig = [...tags].sort().join(",");
447
+ const { data: tdi } = await supabase
448
+ .from("task_difficulty_index")
449
+ .select("avg_difficulty, avg_completion_seconds, avg_completion_rate, task_count, confidence")
450
+ .eq("task_type_id", bestMatch.id).eq("tag_signature", tagSig).maybeSingle();
451
+ if (tdi) {
452
+ suggestedDifficulty = Math.round(Number(tdi.avg_difficulty));
453
+ difficultySource = `tdi_${tdi.confidence}`;
454
+ tdiStats = tdi;
455
+ }
456
+ }
457
+ let relevantSkillIds = [];
458
+ if (tags && tags.length > 0) {
459
+ const { data: skills } = await supabase.from("skills").select("id, name").eq("status", "active");
460
+ if (skills) {
461
+ relevantSkillIds = skills
462
+ .filter((s) => tagWords.some((t) => s.name.toLowerCase().includes(t) || t.includes(s.name.toLowerCase())))
463
+ .map((s) => s.id);
464
+ }
465
+ }
466
+ return toMcpResponse(ok({
467
+ task_type_id: bestMatch.id, task_type_name: bestMatch.name,
468
+ base_xp: bestMatch.base_xp, xp_tier: bestMatch.xp_tier,
469
+ suggested_difficulty: suggestedDifficulty, difficulty_source: difficultySource,
470
+ tdi_stats: tdiStats, relevant_skill_ids: relevantSkillIds,
471
+ match_confidence: bestScore > 0 ? "matched" : "default_fallback",
472
+ }));
473
+ }
474
+ export async function handleMergeProfiles(params) {
475
+ const primary_user_id = params.primary_user_id;
476
+ const secondary_user_id = params.secondary_user_id;
477
+ const verification_method = params.verification_method;
478
+ const verification_token = params.verification_token;
479
+ if (primary_user_id === secondary_user_id)
480
+ return toMcpResponse(fail("不能将档案与自身合并。"));
481
+ const { data: primary } = await supabase.from("users").select("id, main_xp").eq("id", primary_user_id).single();
482
+ const { data: secondary } = await supabase.from("users").select("id, main_xp").eq("id", secondary_user_id).single();
483
+ if (!primary)
484
+ return toMcpResponse(fail("未找到主账户"));
485
+ if (!secondary)
486
+ return toMcpResponse(fail("未找到副账户"));
487
+ if (verification_method === "link_code") {
488
+ const { data: linkCode } = await supabase
489
+ .from("link_codes").select("id, user_id, expires_at, redeemed")
490
+ .eq("code", verification_token).eq("redeemed", false).maybeSingle();
491
+ if (!linkCode)
492
+ return toMcpResponse(fail("链接码无效或已使用"));
493
+ if (new Date(linkCode.expires_at) < new Date())
494
+ return toMcpResponse(fail("链接码已过期"));
495
+ if (linkCode.user_id !== secondary_user_id)
496
+ return toMcpResponse(fail("链接码不属于副账户"));
497
+ await supabase.from("link_codes").update({ redeemed: true, redeemed_at: new Date().toISOString() }).eq("id", linkCode.id);
498
+ }
499
+ else {
500
+ const { data: identity } = await supabase
501
+ .from("user_identities").select("provider_id")
502
+ .eq("user_id", secondary_user_id).eq("provider", "email").maybeSingle();
503
+ if (!identity)
504
+ return toMcpResponse(fail("副账户没有邮箱身份,无法进行 OTP 验证"));
505
+ const { data: otp } = await supabase
506
+ .from("otp_codes").select("id")
507
+ .eq("destination", identity.provider_id).eq("channel", "email").eq("verified", true)
508
+ .eq("code_hash", hashOtp(verification_token))
509
+ .maybeSingle();
510
+ if (!otp)
511
+ return toMcpResponse(fail("OTP 验证码无效或未验证", "请先使用 levelup_request_otp + levelup_verify_otp 验证副账户的邮箱。"));
512
+ }
513
+ const { data: mergeResult, error: mergeError } = await supabase.rpc('merge_user_profiles', {
514
+ p_primary_user_id: primary_user_id,
515
+ p_secondary_user_id: secondary_user_id,
516
+ });
517
+ if (mergeError)
518
+ return toMcpResponse(handleSupabaseError(mergeError, "merge_profiles"));
519
+ if (!mergeResult?.success)
520
+ return toMcpResponse(fail(mergeResult?.error || "合并失败"));
521
+ return toMcpResponse(ok({
522
+ message: "档案合并成功",
523
+ primary_user_id, secondary_user_id,
524
+ agents_moved: mergeResult.agents_moved,
525
+ identities_moved: mergeResult.identities_moved,
526
+ new_total_xp: mergeResult.total_xp,
527
+ secondary_status: "deactivated",
528
+ }));
529
+ }
530
+ /**
531
+ * Register all User tools on the MCP server.
532
+ */
533
+ export function registerUserTools(server) {
534
+ console.error("\n📋 Section 1: Users");
535
+ // ================================================================
536
+ // Tool 1: levelup_register_user
537
+ // ================================================================
538
+ server.registerTool("levelup_register_user", {
539
+ title: "注册用户",
540
+ description: `注册新的 升级用户或静默自动创建档案。
541
+
542
+ 调用此工具创建新用户账户。自动创建时(智能体为未注册的用户追踪工作),
543
+ 请使用 username "Anonymous"。
544
+ 用户会获得一个 profile_code(LVL-XXXX-XXXX)用于跨平台关联。
545
+
546
+ 参数:
547
+ - username(字符串):显示名称。自动创建时使用 "Anonymous"。
548
+ - identity_provider(字符串):平台 — "email"、"telegram"、"claude" 等。
549
+ - identity_id(字符串):用户在该平台上的 ID(默认已哈希)。
550
+ - identity_is_hashed(布尔值,可选):identity_id 是否已预哈希(默认:true)。
551
+ - handle(字符串,可选):个性标识(唯一,小写字母数字 + 下划线)。
552
+ - email(字符串,可选):用于 OTP 登录。
553
+ - is_searchable_by_email(布尔值,可选):允许通过邮箱搜索(默认:false)。
554
+ - wallet_address(字符串,可选):用于未来区块链功能。
555
+ - metadata(对象,可选):额外数据。
556
+
557
+ 返回:
558
+ 用户信息,包含 id、username、profile_code、main_level(1)、main_xp(0),
559
+ 以及 3 个职业等级(trainer、orchestrator、partner — 均为等级 1)。
560
+
561
+ 错误:
562
+ - 如果 handle 已被占用,返回 "Duplicate entry"。
563
+ - 如果 identity_provider + identity_id 已存在,返回 "Duplicate entry"。`,
564
+ inputSchema: {
565
+ username: z.string().min(1).max(100).regex(/^[\p{L}\p{N}\s\-_.]+$/u, "Only letters, numbers, spaces, hyphens, underscores, and dots").describe("Display name"),
566
+ identity_provider: identityProviderSchema,
567
+ identity_id: z.string().min(1).describe("User's ID on the platform"),
568
+ identity_is_hashed: z.boolean().default(true).describe("Whether identity_id is pre-hashed"),
569
+ handle: z.string().min(3).max(30).regex(/^[a-z0-9_]+$/, "Lowercase alphanumeric + underscores only").optional().describe("Vanity handle (unique)"),
570
+ email: z.string().email().optional().describe("Email for OTP login"),
571
+ is_searchable_by_email: z.boolean().default(false).describe("Allow email discovery"),
572
+ wallet_address: z.string().optional().describe("Blockchain wallet address"),
573
+ metadata: metadataSchema,
574
+ },
575
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
576
+ }, async (params) => {
577
+ const profileCode = generateProfileCode();
578
+ // Use secure RPC for atomic user creation (bypasses RLS safely)
579
+ const { data: rpcResult, error: rpcError } = await supabase.rpc('register_user_secure', {
580
+ p_username: params.username,
581
+ p_email: params.email || null,
582
+ p_profile_code: profileCode,
583
+ p_handle: params.handle || null,
584
+ p_is_searchable_by_email: params.is_searchable_by_email,
585
+ p_wallet_address: params.wallet_address || null,
586
+ p_metadata: params.metadata || null,
587
+ p_identity_provider: params.identity_provider,
588
+ p_identity_id: params.identity_id,
589
+ p_identity_is_hashed: params.identity_is_hashed,
590
+ });
591
+ // Fallback to direct insert if RPC fails (doesn't exist or signature mismatch)
592
+ if (rpcError) {
593
+ console.error("[register_user] RPC failed, falling back to direct insert:", rpcError.message);
594
+ const { data: user, error: userError } = await supabase
595
+ .from("users")
596
+ .insert({
597
+ username: params.username,
598
+ email: params.email || null,
599
+ profile_code: profileCode,
600
+ handle: params.handle || null,
601
+ is_searchable_by_email: params.is_searchable_by_email,
602
+ wallet_address: params.wallet_address || null,
603
+ metadata: params.metadata || null,
604
+ })
605
+ .select()
606
+ .single();
607
+ if (userError)
608
+ return toMcpResponse(handleSupabaseError(userError, "register_user"));
609
+ const classRows = ["trainer", "orchestrator", "partner"].map((cls) => ({
610
+ user_id: user.id, class_name: cls, class_level: 1, class_xp: 0,
611
+ }));
612
+ const { error: classError } = await supabase.from("user_class_progress").insert(classRows);
613
+ if (classError)
614
+ console.error("[register_user] Class progress failed:", classError);
615
+ const { error: idError } = await supabase.from("user_identities").insert({
616
+ user_id: user.id,
617
+ provider: params.identity_provider,
618
+ provider_id: params.identity_id,
619
+ is_hashed: params.identity_is_hashed,
620
+ is_primary: true,
621
+ });
622
+ if (idError)
623
+ console.error("[register_user] Identity failed:", idError);
624
+ return toMcpResponse(ok({
625
+ id: user.id, username: user.username, profile_code: user.profile_code,
626
+ handle: user.handle, main_level: user.main_level, main_xp: user.main_xp,
627
+ classes: { trainer: { level: 1, xp: 0 }, orchestrator: { level: 1, xp: 0 }, partner: { level: 1, xp: 0 } },
628
+ created_at: user.created_at,
629
+ }));
630
+ }
631
+ return toMcpResponse(ok({
632
+ id: rpcResult.user_id, username: params.username, profile_code: profileCode,
633
+ handle: params.handle || null, main_level: 1, main_xp: 0,
634
+ classes: { trainer: { level: 1, xp: 0 }, orchestrator: { level: 1, xp: 0 }, partner: { level: 1, xp: 0 } },
635
+ created_at: rpcResult.created_at || new Date().toISOString(),
636
+ }));
637
+ });
638
+ logRegistered("levelup_register_user");
639
+ // ================================================================
640
+ // Tool 2: levelup_get_user_profile
641
+ // ================================================================
642
+ server.registerTool("levelup_get_user_profile", {
643
+ title: "获取用户档案",
644
+ description: `获取用户的公开档案 — 等级、段位、职业、智能体数量、成就。
645
+
646
+ 除非用户开启了邮箱搜索,否则不会暴露 identity_id 或 email。
647
+
648
+ 参数:
649
+ - user_id(uuid):要查询的用户。
650
+
651
+ 返回:
652
+ 公开档案,包含 username、handle、profile_code、main_level、main_xp、
653
+ rank_name、职业等级、智能体数量、成就数量。`,
654
+ inputSchema: { user_id: uuidSchema.describe("The user's ID") },
655
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
656
+ }, async (params) => {
657
+ const profile = await fetchPublicProfile(params.user_id);
658
+ if (!profile)
659
+ return toMcpResponse(fail("未找到用户", "请检查 user_id 或使用 levelup_find_user 查找。"));
660
+ const { count: agentCount } = await supabase
661
+ .from("agents").select("id", { count: "exact", head: true })
662
+ .eq("owner_user_id", params.user_id).eq("status", "active");
663
+ const { count: achievementCount } = await supabase
664
+ .from("user_achievements").select("id", { count: "exact", head: true })
665
+ .eq("user_id", params.user_id);
666
+ return toMcpResponse(ok({ ...profile, agent_count: agentCount || 0, achievement_count: achievementCount || 0 }));
667
+ });
668
+ logRegistered("levelup_get_user_profile");
669
+ // ================================================================
670
+ // Tool 3: levelup_get_user_progress
671
+ // ================================================================
672
+ server.registerTool("levelup_get_user_progress", {
673
+ title: "获取用户进度",
674
+ description: `进度面板 — 查看主等级和 3 个职业的升级所需经验值。
675
+
676
+ 显示当前等级、经验值、升级所需经验值、进度百分比和段位名称,
677
+ 涵盖主等级和各职业(trainer、orchestrator、partner)。
678
+
679
+ 参数:
680
+ - user_id(uuid):要查询的用户。
681
+
682
+ 返回:
683
+ 各赛道进度:{ main, trainer, orchestrator, partner },每项包含
684
+ current_level、current_xp、xp_for_next_level、xp_remaining、progress_pct、rank_name。`,
685
+ inputSchema: { user_id: uuidSchema.describe("The user's ID") },
686
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
687
+ }, async (params) => {
688
+ const { data: user, error: userError } = await supabase
689
+ .from("users").select("id, username, main_level, main_xp").eq("id", params.user_id).single();
690
+ if (userError)
691
+ return toMcpResponse(handleSupabaseError(userError, "get_user_progress"));
692
+ const { data: classes } = await supabase
693
+ .from("user_class_progress").select("class_name, class_level, class_xp").eq("user_id", params.user_id);
694
+ const { data: curves } = await supabase
695
+ .from("level_curves").select("level, xp_required, cumulative_xp")
696
+ .eq("entity_type", "user").order("level", { ascending: true });
697
+ const { data: ranks } = await supabase
698
+ .from("rank_definitions").select("min_level, max_level, rank_name, rank_letter")
699
+ .eq("entity_type", "user").eq("locale", "zh").order("min_level", { ascending: true });
700
+ // Helper: compute progress for a given level + xp
701
+ const computeProgress = (level, xp) => {
702
+ const nextCurve = (curves || []).find((c) => c.level === level + 1);
703
+ const xpForNext = nextCurve?.xp_required || 0;
704
+ const rank = (ranks || []).find((r) => level >= r.min_level && level <= r.max_level);
705
+ const progressPct = xpForNext > 0 ? Math.round((xp / xpForNext) * 100) : 100;
706
+ const rankInfo = formatRankDisplay(level, rank?.rank_letter || null, rank?.rank_name || null, rank?.min_level || 1, rank?.max_level || 5);
707
+ return {
708
+ current_xp: xp, xp_for_next_level: xpForNext,
709
+ xp_remaining: Math.max(0, xpForNext - xp), progress_pct: Math.min(100, progressPct),
710
+ rank_name: rank?.rank_name || null, rank_letter: rank?.rank_letter || null,
711
+ rank_display: rankInfo.rank_display, stars: rankInfo.stars,
712
+ stars_earned: rankInfo.stars_earned, stars_total: rankInfo.stars_total,
713
+ };
714
+ };
715
+ const tracks = {
716
+ main: computeProgress(user.main_level, user.main_xp),
717
+ };
718
+ for (const cls of classes || []) {
719
+ tracks[cls.class_name] = computeProgress(cls.class_level, cls.class_xp);
720
+ }
721
+ return toMcpResponse(ok({ user_id: user.id, username: user.username, tracks }));
722
+ });
723
+ logRegistered("levelup_get_user_progress");
724
+ // ================================================================
725
+ // Tool 4: levelup_find_user
726
+ // ================================================================
727
+ server.registerTool("levelup_find_user", {
728
+ title: "查找用户",
729
+ description: `通过 profile_code、handle 或 email 查找用户(邮箱搜索仅对已开启的用户有效)。
730
+
731
+ 至少需要一个搜索参数。仅返回公开档案。
732
+
733
+ 参数:
734
+ - profile_code(字符串,可选):例如 "LVL-7X3K-9M2P"
735
+ - handle(字符串,可选):例如 "startrainer"
736
+ - email(字符串,可选):仅能找到 is_searchable_by_email = true 的用户
737
+
738
+ 返回:
739
+ 公开档案,未找到则返回错误。`,
740
+ inputSchema: {
741
+ profile_code: z.string().optional().describe('e.g. "LVL-7X3K-9M2P"'),
742
+ handle: z.string().optional().describe("Vanity handle"),
743
+ email: z.string().email().optional().describe("Email (opted-in users only)"),
744
+ },
745
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
746
+ }, async (params) => {
747
+ if (!params.profile_code && !params.handle && !params.email) {
748
+ return toMcpResponse(fail("至少需要一个搜索参数", "请提供 profile_code、handle 或 email。"));
749
+ }
750
+ let userId = null;
751
+ if (params.profile_code) {
752
+ const { data } = await supabase.from("users").select("id").eq("profile_code", params.profile_code).maybeSingle();
753
+ userId = data?.id || null;
754
+ }
755
+ if (!userId && params.handle) {
756
+ const { data } = await supabase.from("users").select("id").eq("handle", params.handle).maybeSingle();
757
+ userId = data?.id || null;
758
+ }
759
+ if (!userId && params.email) {
760
+ const { data } = await supabase.from("users").select("id").eq("email", params.email).eq("is_searchable_by_email", true).maybeSingle();
761
+ userId = data?.id || null;
762
+ }
763
+ if (!userId)
764
+ return toMcpResponse(fail("未找到用户", "请检查搜索参数。邮箱搜索需要用户开启 is_searchable_by_email。"));
765
+ const profile = await fetchPublicProfile(userId);
766
+ return toMcpResponse(ok(profile));
767
+ });
768
+ logRegistered("levelup_find_user");
769
+ // ================================================================
770
+ // Tool 5: levelup_generate_link_code
771
+ // ================================================================
772
+ server.registerTool("levelup_generate_link_code", {
773
+ title: "生成链接码",
774
+ description: `生成用于跨平台账户关联的临时码。
775
+
776
+ 用户将此码提供给另一个平台(例如告诉 Claude 智能体
777
+ "用链接码 LINK-8K3M 关联我的账户"),然后该平台调用 redeem_link_code。
778
+
779
+ 参数:
780
+ - user_id(uuid):已认证的用户。
781
+ - expires_in_minutes(整数,可选):有效期(默认 10 分钟,最长 60 分钟)。
782
+
783
+ 返回:
784
+ { code: "LINK-XXXX", expires_at: 时间戳 }`,
785
+ inputSchema: {
786
+ user_id: uuidSchema.describe("Authenticated user"),
787
+ expires_in_minutes: z.number().int().min(1).max(60).default(10).describe("Expiry in minutes (default 10)"),
788
+ },
789
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
790
+ }, async (params) => {
791
+ const { error: userError } = await supabase.from("users").select("id").eq("id", params.user_id).single();
792
+ if (userError)
793
+ return toMcpResponse(fail("未找到用户", "请检查 user_id。"));
794
+ const code = generateLinkCode();
795
+ const expiresAt = new Date(Date.now() + params.expires_in_minutes * 60 * 1000).toISOString();
796
+ const { data: linkCode, error } = await supabase
797
+ .from("link_codes").insert({ user_id: params.user_id, code, expires_at: expiresAt, redeemed: false })
798
+ .select().single();
799
+ if (error)
800
+ return toMcpResponse(handleSupabaseError(error, "generate_link_code"));
801
+ return toMcpResponse(ok({ code: linkCode.code, expires_at: linkCode.expires_at, expires_in_minutes: params.expires_in_minutes }));
802
+ });
803
+ logRegistered("levelup_generate_link_code");
804
+ // ================================================================
805
+ // Tool 6: levelup_redeem_link_code
806
+ // ================================================================
807
+ server.registerTool("levelup_redeem_link_code", {
808
+ title: "兑换链接码",
809
+ description: `使用来自另一个平台的链接码绑定新的平台身份。
810
+
811
+ 参数:
812
+ - code(字符串):链接码(例如 "LINK-8K3M")。
813
+ - identity_provider(字符串):新平台。
814
+ - identity_id(字符串):用户在新平台上的 ID。
815
+ - identity_is_hashed(布尔值,可选):默认 true。
816
+
817
+ 返回:
818
+ 确认信息 + 用户公开档案。
819
+
820
+ 错误:
821
+ - "链接码未找到或已使用" / "链接码已过期"`,
822
+ inputSchema: {
823
+ code: z.string().min(1).describe('Link code, e.g. "LINK-8K3M"'),
824
+ identity_provider: identityProviderSchema,
825
+ identity_id: z.string().min(1).describe("User's ID on the new platform"),
826
+ identity_is_hashed: z.boolean().default(true).describe("Whether pre-hashed"),
827
+ },
828
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
829
+ }, async (params) => {
830
+ const { data: linkCode } = await supabase
831
+ .from("link_codes").select("id, user_id, expires_at, redeemed")
832
+ .eq("code", params.code).eq("redeemed", false).maybeSingle();
833
+ if (!linkCode)
834
+ return toMcpResponse(fail("链接码未找到或已使用", "请使用 levelup_generate_link_code 生成新的链接码。"));
835
+ if (new Date(linkCode.expires_at) < new Date())
836
+ return toMcpResponse(fail("链接码已过期", "请生成新的链接码。"));
837
+ const { error: idError } = await supabase.from("user_identities").insert({
838
+ user_id: linkCode.user_id, provider: params.identity_provider,
839
+ provider_id: params.identity_id, is_hashed: params.identity_is_hashed, is_primary: false,
840
+ });
841
+ if (idError)
842
+ return toMcpResponse(handleSupabaseError(idError, "redeem_link_code"));
843
+ await supabase.from("link_codes").update({ redeemed: true, redeemed_at: new Date().toISOString() }).eq("id", linkCode.id);
844
+ const profile = await fetchPublicProfile(linkCode.user_id);
845
+ return toMcpResponse(ok({ message: "身份绑定成功", linked_provider: params.identity_provider, profile }));
846
+ });
847
+ logRegistered("levelup_redeem_link_code");
848
+ // ================================================================
849
+ // Tool 7: levelup_link_identity
850
+ // ================================================================
851
+ server.registerTool("levelup_link_identity", {
852
+ title: "绑定身份",
853
+ description: `为已认证的用户添加新的平台身份(无需链接码)。
854
+
855
+ 参数:
856
+ - user_id(uuid):已认证的用户。
857
+ - identity_provider(字符串):新平台。
858
+ - identity_id(字符串):用户在新平台上的 ID。
859
+ - identity_is_hashed(布尔值,可选):默认 true。
860
+
861
+ 返回:
862
+ 已绑定的平台列表(仅名称,不暴露 ID)。`,
863
+ inputSchema: {
864
+ user_id: uuidSchema.describe("Authenticated user"),
865
+ identity_provider: identityProviderSchema,
866
+ identity_id: z.string().min(1).describe("User's ID on the new platform"),
867
+ identity_is_hashed: z.boolean().default(true).describe("Whether pre-hashed"),
868
+ },
869
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
870
+ }, async (params) => {
871
+ const { error: userError } = await supabase.from("users").select("id").eq("id", params.user_id).single();
872
+ if (userError)
873
+ return toMcpResponse(fail("未找到用户", "请检查 user_id。"));
874
+ const { error: idError } = await supabase.from("user_identities").insert({
875
+ user_id: params.user_id, provider: params.identity_provider,
876
+ provider_id: params.identity_id, is_hashed: params.identity_is_hashed, is_primary: false,
877
+ });
878
+ if (idError)
879
+ return toMcpResponse(handleSupabaseError(idError, "link_identity"));
880
+ const { data: identities } = await supabase.from("user_identities").select("provider").eq("user_id", params.user_id);
881
+ return toMcpResponse(ok({
882
+ user_id: params.user_id,
883
+ linked_providers: (identities || []).map((i) => i.provider),
884
+ message: `${params.identity_provider} 绑定成功`,
885
+ }));
886
+ });
887
+ logRegistered("levelup_link_identity");
888
+ // ================================================================
889
+ // Tool 8: levelup_request_otp
890
+ // ================================================================
891
+ server.registerTool("levelup_request_otp", {
892
+ title: "请求验证码",
893
+ description: `向邮箱发送 6 位登录验证码。
894
+
895
+ 邮箱认证的第一步。之后请调用 levelup_verify_otp 完成验证。
896
+ 响应始终为通用格式,防止邮箱枚举攻击。
897
+
898
+ 安全性:哈希存储验证码,最多 3 次尝试,5 分钟过期,有频率限制。
899
+
900
+ 注意:当前仅存储 OTP 用于测试,邮件发送功能将在未来添加。
901
+
902
+ 参数:
903
+ - channel(字符串):"email"(短信暂未支持)。
904
+ - destination(字符串):邮箱地址。
905
+
906
+ 返回:
907
+ 通用提示 "如果账户存在则已发送验证码" + 测试用调试码。`,
908
+ inputSchema: {
909
+ channel: z.enum(["email"]).describe('"email"'),
910
+ destination: z.string().email().describe("Email address"),
911
+ },
912
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
913
+ }, async (params) => {
914
+ const otpCode = String(randomInt(100000, 999999));
915
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
916
+ // Clean up old OTPs for this destination
917
+ await supabase.from("otp_codes").delete()
918
+ .eq("destination", params.destination).eq("channel", params.channel).eq("verified", false);
919
+ const { error } = await supabase.from("otp_codes").insert({
920
+ destination: params.destination, channel: params.channel,
921
+ code_hash: hashOtp(otpCode), attempts: 0, expires_at: expiresAt, verified: false,
922
+ });
923
+ if (error)
924
+ console.error("[request_otp] Insert failed:", error);
925
+ // TODO: Send OTP via email service. For now, the code is only in the DB.
926
+ return toMcpResponse(ok({
927
+ message: "如果此邮箱有对应账户,验证码已发送。",
928
+ channel: params.channel, expires_in_seconds: 300,
929
+ }));
930
+ });
931
+ logRegistered("levelup_request_otp");
932
+ // ================================================================
933
+ // Tool 9: levelup_verify_otp
934
+ // ================================================================
935
+ server.registerTool("levelup_verify_otp", {
936
+ title: "验证 OTP",
937
+ description: `验证 6 位验证码并完成认证。
938
+
939
+ 邮箱认证的第二步(在 request_otp 之后)。最多 3 次尝试。
940
+
941
+ 参数:
942
+ - channel(字符串):"email"
943
+ - destination(字符串):与 request_otp 相同的邮箱。
944
+ - code(字符串):6 位验证码。
945
+
946
+ 返回:
947
+ 验证通过:{ verified: true, user_id, profile_code, is_new_user }
948
+ 验证失败:错误信息 + 剩余尝试次数。`,
949
+ inputSchema: {
950
+ channel: z.enum(["email"]).describe('"email"'),
951
+ destination: z.string().email().describe("Same email from request_otp"),
952
+ code: z.string().length(6).describe("6-digit code"),
953
+ },
954
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
955
+ }, async (params) => {
956
+ const { data: otp } = await supabase
957
+ .from("otp_codes").select("*")
958
+ .eq("destination", params.destination).eq("channel", params.channel)
959
+ .eq("verified", false).gt("expires_at", new Date().toISOString())
960
+ .order("created_at", { ascending: false }).limit(1).maybeSingle();
961
+ if (!otp)
962
+ return toMcpResponse(fail("未找到有效的验证码", "请使用 levelup_request_otp 重新获取验证码。"));
963
+ if (otp.attempts >= 3)
964
+ return toMcpResponse(fail("尝试次数过多,验证码已失效。", "请重新获取验证码。"));
965
+ const codeHashBuf = Buffer.from(otp.code_hash, "utf8");
966
+ const inputHashBuf = Buffer.from(hashOtp(params.code), "utf8");
967
+ const codeValid = codeHashBuf.length === inputHashBuf.length && timingSafeEqual(codeHashBuf, inputHashBuf);
968
+ if (!codeValid) {
969
+ const newAttempts = otp.attempts + 1;
970
+ await supabase.from("otp_codes").update({ attempts: newAttempts }).eq("id", otp.id);
971
+ return toMcpResponse(fail("验证码错误", `剩余 ${3 - newAttempts} 次尝试机会。`));
972
+ }
973
+ await supabase.from("otp_codes").update({ verified: true }).eq("id", otp.id);
974
+ const { data: identity } = await supabase
975
+ .from("user_identities").select("user_id")
976
+ .eq("provider", "email").eq("provider_id", params.destination).maybeSingle();
977
+ if (identity) {
978
+ const { data: user } = await supabase.from("users").select("id, profile_code").eq("id", identity.user_id).single();
979
+ return toMcpResponse(ok({ verified: true, user_id: user?.id, profile_code: user?.profile_code, is_new_user: false }));
980
+ }
981
+ return toMcpResponse(ok({
982
+ verified: true, user_id: null, profile_code: null, is_new_user: true,
983
+ message: "邮箱已验证,但未找到关联账户。请使用 levelup_register_user 创建账户。",
984
+ }));
985
+ });
986
+ logRegistered("levelup_verify_otp");
987
+ // ================================================================
988
+ // Tool 10: levelup_get_onboarding_status
989
+ // ================================================================
990
+ server.registerTool("levelup_get_onboarding_status", {
991
+ title: "获取引导状态",
992
+ description: `检查用户的设置进度并建议下一步操作。当用户询问"我是谁"、"我的档案"、"我的数据"或在对话开始时调用此工具。
993
+
994
+ 自动从环境变量、身份信息、用户名或最近活跃用户中检测用户。
995
+ 所有参数均为可选 — 不传参数即可自动检测。
996
+
997
+ 参数(均为可选):
998
+ - identity_provider(字符串):调用的平台。
999
+ - identity_id(字符串):用户在该平台的 ID。
1000
+ - username(字符串):要查找的用户名。
1001
+
1002
+ 返回:
1003
+ is_registered、user_id、is_personalized、has_handle、has_email、has_agent、
1004
+ has_completed_task、total_xp、active_quests、suggested_next_step。`,
1005
+ inputSchema: {
1006
+ identity_provider: identityProviderSchema.optional(),
1007
+ identity_id: z.string().min(1).optional().describe("User's ID on the platform"),
1008
+ username: z.string().optional().describe("Username to look up"),
1009
+ },
1010
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1011
+ }, async (params) => {
1012
+ let userId = null;
1013
+ // Priority 1: Env vars
1014
+ if (process.env.LEVELUP_USER_ID)
1015
+ userId = process.env.LEVELUP_USER_ID;
1016
+ // Priority 2: Identity provider lookup
1017
+ if (!userId && params.identity_provider && params.identity_id) {
1018
+ const { data: identity } = await supabase
1019
+ .from("user_identities").select("user_id")
1020
+ .eq("provider", params.identity_provider).eq("provider_id", params.identity_id).maybeSingle();
1021
+ if (identity)
1022
+ userId = identity.user_id;
1023
+ }
1024
+ // Priority 3: Username lookup
1025
+ if (!userId && params.username) {
1026
+ const { data: userByName } = await supabase
1027
+ .from("users").select("id").eq("username", params.username).maybeSingle();
1028
+ if (userByName)
1029
+ userId = userByName.id;
1030
+ }
1031
+ // Priority 4: Most recently created user (single-user fallback)
1032
+ if (!userId) {
1033
+ const { data: recentUser } = await supabase
1034
+ .from("users").select("id").order("created_at", { ascending: false }).limit(1).maybeSingle();
1035
+ if (recentUser)
1036
+ userId = recentUser.id;
1037
+ }
1038
+ if (!userId) {
1039
+ return toMcpResponse(ok({ is_registered: false, suggested_next_step: "请使用 levelup_register_user 注册账户" }));
1040
+ }
1041
+ const { data: user } = await supabase
1042
+ .from("users").select("id, username, handle, email, main_xp, profile_code").eq("id", userId).single();
1043
+ if (!user)
1044
+ return toMcpResponse(ok({ is_registered: false, suggested_next_step: "请使用 levelup_register_user 注册账户" }));
1045
+ const isPersonalized = user.username !== "Anonymous";
1046
+ const { count: agentCount } = await supabase.from("agents").select("id", { count: "exact", head: true }).eq("owner_user_id", userId).eq("status", "active");
1047
+ const { count: taskCount } = await supabase.from("tasks").select("id", { count: "exact", head: true }).eq("user_id", userId).eq("status", "completed");
1048
+ const { count: questCount } = await supabase.from("quest_participants").select("id", { count: "exact", head: true }).eq("user_id", userId);
1049
+ let suggestedNextStep = "一切就绪!可以开始执行任务了。";
1050
+ if (!isPersonalized)
1051
+ suggestedNextStep = "请使用 levelup_update_user_profile 个性化你的档案。";
1052
+ else if (!user.handle)
1053
+ suggestedNextStep = "请使用 levelup_update_user_profile 设置你的个人标识。";
1054
+ else if ((agentCount || 0) === 0)
1055
+ suggestedNextStep = "请使用 levelup_register_agent 注册你的第一个智能体。";
1056
+ else if ((taskCount || 0) === 0)
1057
+ suggestedNextStep = "请使用 levelup_start_task 完成你的第一个任务。";
1058
+ return toMcpResponse(ok({
1059
+ is_registered: true, user_id: userId, profile_code: user.profile_code,
1060
+ is_personalized: isPersonalized, has_handle: !!user.handle, has_email: !!user.email,
1061
+ has_agent: (agentCount || 0) > 0, has_completed_task: (taskCount || 0) > 0,
1062
+ total_xp: user.main_xp, active_quests: questCount || 0, suggested_next_step: suggestedNextStep,
1063
+ }));
1064
+ });
1065
+ logRegistered("levelup_get_onboarding_status");
1066
+ // ================================================================
1067
+ // Tool 11: levelup_update_user_profile
1068
+ // ================================================================
1069
+ server.registerTool("levelup_update_user_profile", {
1070
+ title: "更新用户档案",
1071
+ description: `在自动创建后个性化用户档案。仅更新提供的字段。
1072
+
1073
+ 参数:
1074
+ - user_id(uuid):要更新的用户。
1075
+ - username(字符串,可选):新的显示名称。
1076
+ - handle(字符串,可选):设置个性标识(唯一,小写)。
1077
+ - email(字符串,可选):添加邮箱用于 OTP 登录。
1078
+ - is_searchable_by_email(布尔值,可选):允许通过邮箱搜索。
1079
+ - wallet_address(字符串,可选):添加区块链钱包。
1080
+ - metadata(对象,可选):额外档案数据(简介、头像等)。
1081
+
1082
+ 返回:
1083
+ 更新后的档案。
1084
+
1085
+ 错误:
1086
+ - 如果 handle 已被占用,返回 "Duplicate entry"。`,
1087
+ inputSchema: {
1088
+ user_id: uuidSchema.describe("The user to update"),
1089
+ username: z.string().min(1).max(100).optional().describe("New display name"),
1090
+ handle: z.string().min(3).max(30).regex(/^[a-z0-9_]+$/, "Lowercase alphanumeric + underscores").optional().describe("Vanity handle"),
1091
+ email: z.string().email().optional().describe("Email for OTP login"),
1092
+ is_searchable_by_email: z.boolean().optional().describe("Allow email discovery"),
1093
+ wallet_address: z.string().optional().describe("Blockchain wallet"),
1094
+ metadata: metadataSchema,
1095
+ },
1096
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1097
+ }, async (params) => {
1098
+ const updates = {};
1099
+ if (params.username !== undefined)
1100
+ updates.username = params.username;
1101
+ if (params.handle !== undefined)
1102
+ updates.handle = params.handle;
1103
+ if (params.email !== undefined)
1104
+ updates.email = params.email;
1105
+ if (params.is_searchable_by_email !== undefined)
1106
+ updates.is_searchable_by_email = params.is_searchable_by_email;
1107
+ if (params.wallet_address !== undefined)
1108
+ updates.wallet_address = params.wallet_address;
1109
+ if (params.metadata !== undefined)
1110
+ updates.metadata = params.metadata;
1111
+ if (Object.keys(updates).length === 0) {
1112
+ return toMcpResponse(fail("没有需要更新的字段", "请至少提供一个字段。"));
1113
+ }
1114
+ const { data: rpcResult, error } = await supabase.rpc('update_user_profile', {
1115
+ p_user_id: params.user_id,
1116
+ p_updates: updates,
1117
+ });
1118
+ if (error)
1119
+ return toMcpResponse(handleSupabaseError(error, "update_user_profile"));
1120
+ if (!rpcResult?.success)
1121
+ return toMcpResponse(fail(rpcResult?.error || "更新失败"));
1122
+ // If email was added, upsert the email identity
1123
+ if (params.email) {
1124
+ const { data: existing } = await supabase
1125
+ .from("user_identities").select("id")
1126
+ .eq("user_id", params.user_id).eq("provider", "email").maybeSingle();
1127
+ if (existing) {
1128
+ await supabase.from("user_identities").update({ provider_id: params.email, is_hashed: false }).eq("id", existing.id);
1129
+ }
1130
+ else {
1131
+ await supabase.from("user_identities").insert({
1132
+ user_id: params.user_id, provider: "email", provider_id: params.email, is_hashed: false, is_primary: false,
1133
+ });
1134
+ }
1135
+ }
1136
+ const profile = await fetchPublicProfile(params.user_id);
1137
+ return toMcpResponse(ok(profile));
1138
+ });
1139
+ logRegistered("levelup_update_user_profile");
1140
+ // ================================================================
1141
+ // Tool 12: levelup_infer_task_type
1142
+ // ================================================================
1143
+ server.registerTool("levelup_infer_task_type", {
1144
+ title: "推断任务类型",
1145
+ description: `在开始任务前自动分类工作。智能体静默调用。
1146
+
1147
+ 根据工作描述,找到最匹配的任务类型,并从任务难度指数(TDI)中建议难度。
1148
+
1149
+ 参数:
1150
+ - description(字符串):工作的简要描述(例如"编写 Python 脚本分析 CSV 数据")。
1151
+ - tags(字符串数组,可选):用于更好匹配的关键词。
1152
+
1153
+ 返回:
1154
+ task_type_id、task_type_name、base_xp、suggested_difficulty(1-7)、
1155
+ difficulty_source、tdi_stats、relevant_skill_ids、match_confidence。`,
1156
+ inputSchema: {
1157
+ description: z.string().min(3).max(500).describe("Brief description of the work"),
1158
+ tags: z.array(z.string()).optional().describe("Keywords for matching"),
1159
+ },
1160
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1161
+ }, async (params) => {
1162
+ const { data: taskTypes } = await supabase
1163
+ .from("task_types").select("id, name, description, base_xp, xp_tier, category").eq("status", "active");
1164
+ if (!taskTypes || taskTypes.length === 0) {
1165
+ return toMcpResponse(fail("未找到任务类型", "任务类型目录可能为空,请使用 levelup_suggest_task_type。"));
1166
+ }
1167
+ // Simple keyword matching
1168
+ const descWords = params.description.toLowerCase().split(/\s+/);
1169
+ const tagWords = (params.tags || []).map((t) => t.toLowerCase());
1170
+ const allWords = [...descWords, ...tagWords];
1171
+ let bestMatch = taskTypes[0];
1172
+ let bestScore = 0;
1173
+ for (const tt of taskTypes) {
1174
+ const ttText = `${tt.name} ${tt.description || ""} ${tt.category || ""}`.toLowerCase();
1175
+ let score = 0;
1176
+ for (const word of allWords) {
1177
+ if (word.length > 2 && ttText.includes(word))
1178
+ score++;
1179
+ }
1180
+ if (score > bestScore) {
1181
+ bestScore = score;
1182
+ bestMatch = tt;
1183
+ }
1184
+ }
1185
+ // TDI lookup
1186
+ let suggestedDifficulty = 3;
1187
+ let difficultySource = "default";
1188
+ let tdiStats = null;
1189
+ if (params.tags && params.tags.length > 0) {
1190
+ const tagSig = [...params.tags].sort().join(",");
1191
+ const { data: tdi } = await supabase
1192
+ .from("task_difficulty_index")
1193
+ .select("avg_difficulty, avg_completion_seconds, avg_completion_rate, task_count, confidence")
1194
+ .eq("task_type_id", bestMatch.id).eq("tag_signature", tagSig).maybeSingle();
1195
+ if (tdi) {
1196
+ suggestedDifficulty = Math.round(Number(tdi.avg_difficulty));
1197
+ difficultySource = `tdi_${tdi.confidence}`;
1198
+ tdiStats = tdi;
1199
+ }
1200
+ }
1201
+ // Find relevant skills
1202
+ let relevantSkillIds = [];
1203
+ if (params.tags && params.tags.length > 0) {
1204
+ const { data: skills } = await supabase.from("skills").select("id, name").eq("status", "active");
1205
+ if (skills) {
1206
+ relevantSkillIds = skills
1207
+ .filter((s) => tagWords.some((t) => s.name.toLowerCase().includes(t) || t.includes(s.name.toLowerCase())))
1208
+ .map((s) => s.id);
1209
+ }
1210
+ }
1211
+ return toMcpResponse(ok({
1212
+ task_type_id: bestMatch.id, task_type_name: bestMatch.name,
1213
+ base_xp: bestMatch.base_xp, xp_tier: bestMatch.xp_tier,
1214
+ suggested_difficulty: suggestedDifficulty, difficulty_source: difficultySource,
1215
+ tdi_stats: tdiStats, relevant_skill_ids: relevantSkillIds,
1216
+ match_confidence: bestScore > 0 ? "matched" : "default_fallback",
1217
+ }));
1218
+ });
1219
+ logRegistered("levelup_infer_task_type");
1220
+ // ================================================================
1221
+ // Tool 13: levelup_merge_profiles
1222
+ // ================================================================
1223
+ server.registerTool("levelup_merge_profiles", {
1224
+ title: "合并档案",
1225
+ description: `合并跨平台自动创建的重复用户档案。
1226
+
1227
+ 将副档案的所有智能体、经验值、任务和身份迁移到主档案中。
1228
+ 重新计算总量。副档案将被软删除。
1229
+
1230
+ ⚠️ 破坏性操作:副档案将被永久停用。
1231
+
1232
+ 参数:
1233
+ - primary_user_id(uuid):要保留的档案。
1234
+ - secondary_user_id(uuid):要合并的档案(合并后停用)。
1235
+ - verification_method(字符串):"link_code" 或 "otp"。
1236
+ - verification_token(字符串):证明对两个账户的所有权。
1237
+
1238
+ 返回:
1239
+ 合并摘要:迁移的智能体数、迁移的身份数、新的总经验值。`,
1240
+ inputSchema: {
1241
+ primary_user_id: uuidSchema.describe("Profile to keep"),
1242
+ secondary_user_id: uuidSchema.describe("Profile to merge in (will be deactivated)"),
1243
+ verification_method: z.enum(["link_code", "otp"]).describe("Verification method used"),
1244
+ verification_token: z.string().min(1).describe("Proof of ownership"),
1245
+ },
1246
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1247
+ }, async (params) => {
1248
+ const { primary_user_id, secondary_user_id } = params;
1249
+ if (primary_user_id === secondary_user_id)
1250
+ return toMcpResponse(fail("不能将档案与自身合并。"));
1251
+ const { data: primary } = await supabase.from("users").select("id, main_xp").eq("id", primary_user_id).single();
1252
+ const { data: secondary } = await supabase.from("users").select("id, main_xp").eq("id", secondary_user_id).single();
1253
+ if (!primary)
1254
+ return toMcpResponse(fail("未找到主账户"));
1255
+ if (!secondary)
1256
+ return toMcpResponse(fail("未找到副账户"));
1257
+ // Verify ownership of the secondary account
1258
+ if (params.verification_method === "link_code") {
1259
+ const { data: linkCode } = await supabase
1260
+ .from("link_codes").select("id, user_id, expires_at, redeemed")
1261
+ .eq("code", params.verification_token).eq("redeemed", false).maybeSingle();
1262
+ if (!linkCode)
1263
+ return toMcpResponse(fail("链接码无效或已使用"));
1264
+ if (new Date(linkCode.expires_at) < new Date())
1265
+ return toMcpResponse(fail("链接码已过期"));
1266
+ if (linkCode.user_id !== secondary_user_id)
1267
+ return toMcpResponse(fail("链接码不属于副账户"));
1268
+ await supabase.from("link_codes").update({ redeemed: true, redeemed_at: new Date().toISOString() }).eq("id", linkCode.id);
1269
+ }
1270
+ else {
1271
+ // OTP verification: check that the secondary account's email was recently verified
1272
+ const { data: identity } = await supabase
1273
+ .from("user_identities").select("provider_id")
1274
+ .eq("user_id", secondary_user_id).eq("provider", "email").maybeSingle();
1275
+ if (!identity)
1276
+ return toMcpResponse(fail("副账户没有邮箱身份,无法进行 OTP 验证"));
1277
+ const { data: otp } = await supabase
1278
+ .from("otp_codes").select("id")
1279
+ .eq("destination", identity.provider_id).eq("channel", "email").eq("verified", true)
1280
+ .eq("code_hash", hashOtp(params.verification_token))
1281
+ .maybeSingle();
1282
+ if (!otp)
1283
+ return toMcpResponse(fail("OTP 验证码无效或未验证", "请先使用 levelup_request_otp + levelup_verify_otp 验证副账户的邮箱。"));
1284
+ }
1285
+ // Execute merge atomically via RPC
1286
+ const { data: mergeResult, error: mergeError } = await supabase.rpc('merge_user_profiles', {
1287
+ p_primary_user_id: primary_user_id,
1288
+ p_secondary_user_id: secondary_user_id,
1289
+ });
1290
+ if (mergeError)
1291
+ return toMcpResponse(handleSupabaseError(mergeError, "merge_profiles"));
1292
+ if (!mergeResult?.success)
1293
+ return toMcpResponse(fail(mergeResult?.error || "合并失败"));
1294
+ return toMcpResponse(ok({
1295
+ message: "档案合并成功",
1296
+ primary_user_id, secondary_user_id,
1297
+ agents_moved: mergeResult.agents_moved,
1298
+ identities_moved: mergeResult.identities_moved,
1299
+ new_total_xp: mergeResult.total_xp,
1300
+ secondary_status: "deactivated",
1301
+ }));
1302
+ });
1303
+ logRegistered("levelup_merge_profiles");
1304
+ console.error(` → 13 of 13 user tools registered ✅`);
1305
+ }
1306
+ //# sourceMappingURL=users.js.map