kiku-agent 1.3.2 → 1.3.4

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 (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -12,7 +12,7 @@ export const discographyData = [
12
12
  { id: "stay-with-me", titleEn: "Stay With Me", titleCn: "伴", year: 2024, category: "ost", duration: "3:30", youtubeId: "N7pWFylNQDM", work: "I Want to Go Against the Wind" },
13
13
  { id: "wu-chang-sheng", titleEn: "Longevity By Mistake", titleCn: "误长生", year: 2023, category: "ost", duration: "3:50", youtubeId: "GSZqFPkBMws", work: "Beauty of Resilience" },
14
14
  { id: "reng-xun", titleEn: "Still Searching", titleCn: "仍寻", year: 2023, category: "ost", duration: "5:01", youtubeId: "nsTL6g_UOqE", work: "Beauty of Resilience" },
15
- { id: "dew-before-dawn", titleEn: "Dew Before Dawn (Dewdrops)", titleCn: "露珠在晨曦前消散", year: 2026, category: "ost", duration: "4:42", youtubeId: "D-vEPdasbLo", work: "Veil of Shadows", aliases: ["Dewdrops", "Dewdrops Dissipate Before Dawn", "Dew Before Dawn", "露珠在晨曦前消散"] },
15
+ { id: "dew-before-dawn", titleEn: "Dew Before Dawn (Dewdrops)", titleCn: "露珠在晨曦前消散", year: 2026, category: "ost", duration: "4:42", youtubeId: "D-vEPdasbLo", work: "Veil of Shadows / In Blossom", aliases: ["Dewdrops", "Dewdrops Dissipate Before Dawn", "Dew Before Dawn", "露珠在晨曦前消散", "In Blossom", "花间令", "露水"] },
16
16
  { id: "tu-hei-su", titleEn: "Melatonin", titleCn: "褪黑素", year: 2024, category: "stage", duration: "3:22", youtubeId: "1t0E5bVqGIQ" },
17
17
  { id: "you-shan-lian", titleEn: "You Shan Lian", titleCn: "游山恋", year: 2024, category: "stage", duration: "4:06", youtubeId: "ZaGQuDsN0pc" },
18
18
  { id: "mo-nu-da-mao-xian", titleEn: "The Witch's Great Adventure", titleCn: "魔女大冒险", year: 2024, category: "stage", duration: "5:59", youtubeId: "rzn_nqmBV-M" },
@@ -29,7 +29,7 @@ export const discographyData = [
29
29
  { id: "thousand-years", titleEn: "A Millennium's Wait", titleCn: "千年等一回", year: 2019, category: "ost", duration: "4:05", youtubeId: "-vf6jU96WI4", work: "The Legend of White Snake" },
30
30
  { id: "du-qing", titleEn: "Du Qing", titleCn: "渡情", year: 2019, category: "ost", duration: "3:30", youtubeId: "1dmp3hUhVBI", work: "The Legend of White Snake" },
31
31
  { id: "qing-cheng-shan-xia", titleEn: "Bai Suzhen under Mount Qingcheng", titleCn: "青城山下白素贞", year: 2019, category: "ost", duration: "3:22", youtubeId: "wVZbHrI0q24", work: "The Legend of White Snake" },
32
- { id: "love-emergency", titleEn: "Love Emergency", titleCn: "恋爱告急", year: 2019, category: "single", duration: "3:35", youtubeId: "5MLPdCmnlmE" },
32
+ { id: "love-emergency", titleEn: "Love Emergency", titleCn: "恋爱告急", year: 2019, category: "single", duration: "3:35", youtubeId: "5MLPdCmnlmE", aliases: ["Heartbeat Alert", "Love Emergency", "恋爱告急"] },
33
33
  { id: "tan-yun-xi", titleEn: "Sigh of Yunxi", titleCn: "叹云兮", year: 2018, category: "ost", duration: "3:50", youtubeId: "oEgxqij58LQ", work: "Legend of Yunxi" },
34
34
  { id: "falling-flowers", titleEn: "Falling Flowers", titleCn: "落花成泥", year: 2018, category: "ost", duration: "3:42", youtubeId: "cWibu5ISTyw", work: "Legend of Yunxi" },
35
35
  { id: "hong-zhao-yuan", titleEn: "Hong Zhao Yuan", titleCn: "红昭愿", year: 2018, category: "stage", duration: "3:30", youtubeId: "6gFUB9eDFD0" },
@@ -0,0 +1,17 @@
1
+ {
2
+ "mcpServers": {
3
+ "kiku": {
4
+ "command": "npx",
5
+ "args": ["-y", "-p", "kiku-agent", "kiku-mcp"],
6
+ "env": {
7
+ "KIKU_MCP_ENABLE_WEB_TOOLS": "true"
8
+ }
9
+ },
10
+ "kiku-remote": {
11
+ "url": "https://jujingyi-kikuuu.vercel.app/mcp",
12
+ "headers": {
13
+ "X-API-Key": "YOUR_API_KEY"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -28,7 +28,7 @@ export async function handleMcpMessage(req, res) {
28
28
  }
29
29
 
30
30
  export async function handleMcpHttp(req, res) {
31
- const security = securityMiddleware(req, res);
31
+ const security = await securityMiddleware(req, res);
32
32
  if (security.blocked || !validateRequestSize(req, res)) return;
33
33
 
34
34
  if (req.method !== 'POST') {
@@ -40,12 +40,16 @@ export async function handleMcpHttp(req, res) {
40
40
  });
41
41
  }
42
42
 
43
- const rate = checkRateLimit(`mcp-http:${req.socket?.remoteAddress || 'unknown'}`);
43
+ const auth = security.auth || await validateApiKey(req);
44
+ const rateBucket = auth.keyPrefix ? `mcp-key:${auth.keyPrefix}` : `mcp-http:${req.socket?.remoteAddress || 'unknown'}`;
45
+ const maxLimit = auth.rateLimit || 30;
46
+ const rate = checkRateLimit(rateBucket, maxLimit);
44
47
  if (!rate.allowed) return sendJson(res, 429, { error: rate.error }, { 'Retry-After': '60' });
48
+ res.setHeader('X-RateLimit-Limit', String(maxLimit));
45
49
  res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
46
50
 
47
- const authorized = !!process.env.MCP_API_KEY && validateApiKey(req).valid;
48
- const server = createMcpServer({ transport: 'http', authorized });
51
+ const authorized = auth.valid && auth.authorized !== false;
52
+ const server = createMcpServer({ transport: 'http', authorized, tier: auth.tier });
49
53
  const transport = new StreamableHTTPServerTransport({
50
54
  sessionIdGenerator: undefined,
51
55
  enableJsonResponse: true,
@@ -1,7 +1,106 @@
1
- import { timingSafeEqual } from 'node:crypto';
1
+ import { timingSafeEqual, createHmac, createHash } from 'node:crypto';
2
+
3
+ function getSupabaseConfig() {
4
+ const url = process.env.SUPABASE_URL || '';
5
+ const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
6
+ const anonKey = process.env.SUPABASE_ANON_KEY || '';
7
+ const isConfigured = Boolean(url && (serviceKey || anonKey));
8
+ return { url, serviceKey, anonKey, isConfigured };
9
+ }
10
+
11
+ const API_KEY_PEPPER = process.env.API_KEY_PEPPER || process.env.KIKU_KEY_PEPPER || 'kiku_mcp_pepper_secret_2026';
12
+
13
+ function hashApiKey(rawKey) {
14
+ if (!rawKey || typeof rawKey !== 'string') return '';
15
+ return createHmac('sha256', API_KEY_PEPPER).update(rawKey.trim()).digest('hex');
16
+ }
17
+
18
+ function hashApiKeyLegacy(rawKey) {
19
+ if (!rawKey || typeof rawKey !== 'string') return '';
20
+ return createHash('sha256').update(rawKey.trim()).digest('hex');
21
+ }
22
+
23
+ function safeCompare(a, b) {
24
+ if (!a || !b || typeof a !== 'string' || typeof b !== 'string') return false;
25
+ const bufA = Buffer.from(a);
26
+ const bufB = Buffer.from(b);
27
+ if (bufA.length !== bufB.length) return false;
28
+ return timingSafeEqual(bufA, bufB);
29
+ }
30
+
31
+ const mcpApiKeyCache = new Map();
32
+ const MCP_API_KEY_CACHE_MAX = 5000;
33
+
34
+ export function setMcpApiKeyCache(hash, entry) {
35
+ if (mcpApiKeyCache.size >= MCP_API_KEY_CACHE_MAX) {
36
+ const oldest = mcpApiKeyCache.keys().next().value;
37
+ if (oldest) mcpApiKeyCache.delete(oldest);
38
+ }
39
+ mcpApiKeyCache.set(hash, entry);
40
+ }
41
+
42
+ export const dynamicMcpKeysStore = new Map();
43
+
44
+ export async function saveMcpApiKeyToSupabase({ apiKey, tier = 'mcp_vip', rateLimit = 1000 }) {
45
+ const hash = hashApiKey(apiKey);
46
+ const keyPrefix = apiKey.slice(0, 16);
47
+ const entry = { apiKey, tier, rateLimit, isActive: true, validUntil: Date.now() + 300000, keyPrefix };
48
+ setMcpApiKeyCache(hash, entry);
49
+ dynamicMcpKeysStore.set(apiKey, { tier, rateLimit, keyPrefix });
50
+
51
+ const { url, serviceKey, isConfigured } = getSupabaseConfig();
52
+ if (!isConfigured || !serviceKey) {
53
+ return { stored: 'memory', keyPrefix, hash };
54
+ }
55
+
56
+ try {
57
+ const controller = new AbortController();
58
+ const timeoutId = setTimeout(() => controller.abort(), 3000);
59
+ const payload = JSON.stringify({
60
+ key_prefix: keyPrefix,
61
+ key_hash: hash,
62
+ tier,
63
+ rate_limit_per_min: rateLimit,
64
+ is_active: true
65
+ });
66
+ const headers = {
67
+ 'apikey': serviceKey,
68
+ 'Authorization': `Bearer ${serviceKey}`,
69
+ 'Content-Type': 'application/json',
70
+ 'Prefer': 'return=representation'
71
+ };
72
+
73
+ let res = await fetch(`${url}/rest/v1/jujingyi_kikuuu_api_keys`, {
74
+ method: 'POST',
75
+ headers,
76
+ body: payload,
77
+ signal: controller.signal
78
+ });
79
+ if (res.status === 404) {
80
+ res = await fetch(`${url}/rest/v1/api_keys`, {
81
+ method: 'POST',
82
+ headers,
83
+ body: payload,
84
+ signal: controller.signal
85
+ });
86
+ }
87
+ clearTimeout(timeoutId);
88
+ return { stored: res.ok ? 'supabase' : 'memory', keyPrefix, hash };
89
+ } catch (err) {
90
+ return { stored: 'memory', keyPrefix, hash, error: err.message };
91
+ }
92
+ }
2
93
 
3
94
  const SECURITY_CONFIG = {
4
- apiKeyRequired: Boolean(process.env.MCP_API_KEY || process.env.NODE_ENV === 'production'),
95
+ get apiKeyRequired() {
96
+ return Boolean(
97
+ process.env.MCP_API_KEY ||
98
+ process.env.MCP_API_KEYS ||
99
+ process.env.KIKU_MASTER_KEYS ||
100
+ process.env.KIKU_MASTER_KEY ||
101
+ process.env.NODE_ENV === 'production'
102
+ );
103
+ },
5
104
 
6
105
  rateLimitWindowMs: 60_000,
7
106
  rateLimitMaxRequests: 30,
@@ -16,9 +115,7 @@ const SECURITY_CONFIG = {
16
115
 
17
116
  export const MAX_REQUEST_BODY_SIZE = SECURITY_CONFIG.maxRequestBodyBytes;
18
117
 
19
- export function validateApiKey(req) {
20
- if (!SECURITY_CONFIG.apiKeyRequired) return { valid: true };
21
-
118
+ export async function validateApiKey(req) {
22
119
  let key = req?.headers?.['x-api-key'];
23
120
  if (!key) {
24
121
  const authHeader = req?.headers?.['authorization'];
@@ -26,35 +123,132 @@ export function validateApiKey(req) {
26
123
  key = authHeader.slice(7).trim();
27
124
  }
28
125
  }
29
- if (!process.env.MCP_API_KEY) {
30
- return { valid: false, error: 'MCP API key is not configured on the server. Please set MCP_API_KEY.' };
126
+
127
+ if (!key) {
128
+ if (!SECURITY_CONFIG.apiKeyRequired) {
129
+ return { valid: true, authorized: false, tier: 'anonymous', rateLimit: SECURITY_CONFIG.rateLimitMaxRequests };
130
+ }
131
+ return { valid: false, error: 'Missing API key. Set X-API-Key header or Authorization: Bearer <key>.' };
132
+ }
133
+
134
+ const trimmed = String(key).trim();
135
+
136
+ if (process.env.MCP_API_KEY && safeCompare(trimmed, process.env.MCP_API_KEY.trim())) {
137
+ return { valid: true, authorized: true, tier: 'mcp_vip', rateLimit: 1000, keyPrefix: 'mcp_env' };
31
138
  }
32
- if (!key) return { valid: false, error: 'Missing API key. Set X-API-Key header or Authorization: Bearer <key>.' };
33
- const expected = Buffer.from(process.env.MCP_API_KEY || '');
34
- const received = Buffer.from(String(key));
35
- if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
36
- return { valid: false, error: 'Invalid API key.' };
139
+
140
+ if (process.env.MCP_API_KEYS) {
141
+ const envKeys = process.env.MCP_API_KEYS.split(',').map(s => s.trim()).filter(Boolean);
142
+ for (const ek of envKeys) {
143
+ if (safeCompare(trimmed, ek)) {
144
+ return { valid: true, authorized: true, tier: 'mcp_vip', rateLimit: 1000, keyPrefix: 'mcp_env' };
145
+ }
146
+ }
147
+ }
148
+
149
+
150
+ const masterKeys = [
151
+ ...(process.env.KIKU_MASTER_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
152
+ ...(process.env.KIKU_MASTER_KEY ? [process.env.KIKU_MASTER_KEY.trim()] : [])
153
+ ];
154
+ for (const mk of masterKeys) {
155
+ if (safeCompare(trimmed, mk)) {
156
+ return { valid: true, authorized: true, tier: 'master_vip', rateLimit: 2000, keyPrefix: 'kiku_master' };
157
+ }
158
+ }
159
+
160
+
161
+ const memoryKey = dynamicMcpKeysStore.get(trimmed);
162
+ if (memoryKey) {
163
+ return {
164
+ valid: true,
165
+ authorized: true,
166
+ tier: memoryKey.tier || 'mcp_vip',
167
+ rateLimit: memoryKey.rateLimit || 1000,
168
+ keyPrefix: memoryKey.keyPrefix || trimmed.slice(0, 16)
169
+ };
37
170
  }
38
- return { valid: true };
171
+
172
+
173
+ const hash = hashApiKey(trimmed);
174
+ const legacyHash = hashApiKeyLegacy(trimmed);
175
+ const cached = mcpApiKeyCache.get(hash) || mcpApiKeyCache.get(legacyHash);
176
+ if (cached && Date.now() < cached.validUntil) {
177
+ if (cached.isActive) {
178
+ return {
179
+ valid: true,
180
+ authorized: true,
181
+ tier: cached.tier || 'mcp_vip',
182
+ rateLimit: cached.rateLimit || 1000,
183
+ keyPrefix: cached.keyPrefix || trimmed.slice(0, 16)
184
+ };
185
+ }
186
+ return { valid: false, error: 'API key has been revoked or expired.' };
187
+ }
188
+
189
+
190
+ const { url, serviceKey, anonKey, isConfigured } = getSupabaseConfig();
191
+ if (isConfigured) {
192
+ try {
193
+ const apiKeyHeader = serviceKey || anonKey;
194
+ const controller = new AbortController();
195
+ const timeoutId = setTimeout(() => controller.abort(), 2500);
196
+ const headers = {
197
+ 'apikey': apiKeyHeader,
198
+ 'Authorization': `Bearer ${apiKeyHeader}`,
199
+ 'Content-Type': 'application/json'
200
+ };
201
+ let res = await fetch(`${url}/rest/v1/jujingyi_kikuuu_api_keys?or=(key_hash.eq.${hash},key_hash.eq.${legacyHash})&is_active=eq.true&select=tier,rate_limit_per_min,expires_at`, {
202
+ headers,
203
+ signal: controller.signal
204
+ });
205
+ if (res.status === 404) {
206
+ res = await fetch(`${url}/rest/v1/api_keys?or=(key_hash.eq.${hash},key_hash.eq.${legacyHash})&is_active=eq.true&select=tier,rate_limit_per_min,expires_at`, {
207
+ headers,
208
+ signal: controller.signal
209
+ });
210
+ }
211
+ clearTimeout(timeoutId);
212
+
213
+ if (res.ok) {
214
+ const rows = await res.json();
215
+ if (Array.isArray(rows) && rows.length > 0) {
216
+ const row = rows[0];
217
+ if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) {
218
+ setMcpApiKeyCache(hash, { isActive: false, validUntil: Date.now() + 60000 });
219
+ return { valid: false, error: 'API key has expired.' };
220
+ }
221
+ const tier = row.tier || 'mcp_vip';
222
+ const rateLimit = Number(row.rate_limit_per_min) || 1000;
223
+ const keyPrefix = trimmed.slice(0, 16);
224
+ const result = { valid: true, authorized: true, tier, rateLimit, keyPrefix };
225
+ setMcpApiKeyCache(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
226
+ return result;
227
+ }
228
+ }
229
+ } catch {}
230
+ }
231
+
232
+ return { valid: false, error: 'Invalid API key.' };
39
233
  }
40
234
 
41
235
  const rateLimitStore = new Map();
42
236
 
43
- export function checkRateLimit(sessionId) {
237
+ export function checkRateLimit(sessionId, maxRequests = SECURITY_CONFIG.rateLimitMaxRequests) {
44
238
  const now = Date.now();
45
239
  const entry = rateLimitStore.get(sessionId);
46
240
 
47
241
  if (!entry || now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs) {
48
242
  rateLimitStore.set(sessionId, { count: 1, windowStart: now });
49
- return { allowed: true, remaining: SECURITY_CONFIG.rateLimitMaxRequests - 1 };
243
+ return { allowed: true, remaining: maxRequests - 1, limit: maxRequests };
50
244
  }
51
245
 
52
246
  entry.count++;
53
- const remaining = SECURITY_CONFIG.rateLimitMaxRequests - entry.count;
247
+ const remaining = maxRequests - entry.count;
54
248
  if (remaining < 0) {
55
- return { allowed: false, error: `Rate limit exceeded. Max ${SECURITY_CONFIG.rateLimitMaxRequests} requests per minute.` };
249
+ return { allowed: false, remaining: 0, limit: maxRequests, error: `Rate limit exceeded. Max ${maxRequests} requests per minute.` };
56
250
  }
57
- return { allowed: true, remaining };
251
+ return { allowed: true, remaining, limit: maxRequests };
58
252
  }
59
253
 
60
254
  const rateLimitCleanupTimer = setInterval(() => {
@@ -64,6 +258,11 @@ const rateLimitCleanupTimer = setInterval(() => {
64
258
  rateLimitStore.delete(id);
65
259
  }
66
260
  }
261
+ for (const [hash, entry] of mcpApiKeyCache) {
262
+ if (entry.validUntil && entry.validUntil < now) {
263
+ mcpApiKeyCache.delete(hash);
264
+ }
265
+ }
67
266
  }, 60_000);
68
267
  rateLimitCleanupTimer.unref?.();
69
268
 
@@ -121,7 +320,7 @@ export function setCorsHeaders(res, origin) {
121
320
  return isAllowed;
122
321
  }
123
322
 
124
- export function securityMiddleware(req, res) {
323
+ export async function securityMiddleware(req, res) {
125
324
  const origin = req.headers.origin || '';
126
325
  const corsOk = setCorsHeaders(res, origin);
127
326
 
@@ -138,7 +337,7 @@ export function securityMiddleware(req, res) {
138
337
  return { blocked: true };
139
338
  }
140
339
 
141
- const auth = validateApiKey(req);
340
+ const auth = await validateApiKey(req);
142
341
  if (!auth.valid) {
143
342
  res.statusCode = 401;
144
343
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -146,7 +345,7 @@ export function securityMiddleware(req, res) {
146
345
  return { blocked: true };
147
346
  }
148
347
 
149
- return { blocked: false };
348
+ return { blocked: false, auth };
150
349
  }
151
350
 
152
351
  export function getSecurityConfig() {
@@ -9,13 +9,14 @@ const WRITE_TOOLS = new Set([
9
9
  'manage_skill',
10
10
  'play_music', 'stop_music', 'pause_music', 'resume_music',
11
11
  'mute_music', 'unmute_music', 'next_track', 'prev_track',
12
+ 'set_volume', 'seek_music', 'set_repeat_mode', 'set_shuffle_mode', 'toggle_video_mode',
12
13
  'add_to_queue', 'remove_from_queue', 'clear_queue',
13
14
  ]);
14
15
 
15
-
16
16
  const NON_IDEMPOTENT_TOOLS = new Set([
17
17
  'manage_skill', 'play_music', 'add_to_queue', 'remove_from_queue',
18
18
  'clear_queue', 'next_track', 'prev_track', 'run_quiz',
19
+ 'set_volume', 'seek_music', 'set_repeat_mode', 'set_shuffle_mode', 'toggle_video_mode',
19
20
  ]);
20
21
 
21
22
  const OPEN_WORLD_TOOLS = new Set([
@@ -8,8 +8,8 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
8
8
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9
9
  import { handleMcpHttp } from '../handler.js';
10
10
  import sseHandler from '../../../api/mcp/sse.js';
11
- import messageHandler from '../../../api/mcp/message.js';
12
- import { PACKAGE_VERSION } from '../../agent/version.js';
11
+ import messageHandler from '../../../api/mcp/message.js';
12
+ import { PACKAGE_VERSION } from '../../agent/version.js';
13
13
 
14
14
  const stdioServerPath = fileURLToPath(new URL('../stdio.js', import.meta.url));
15
15
 
@@ -20,7 +20,7 @@ test('MCP client connects over stdio and exercises tools, resources, and prompts
20
20
  cwd: process.cwd(),
21
21
  stderr: 'pipe',
22
22
  });
23
- const client = new Client({ name: 'kiku-mcp-integration-test', version: PACKAGE_VERSION }, { capabilities: {} });
23
+ const client = new Client({ name: 'kiku-mcp-integration-test', version: PACKAGE_VERSION }, { capabilities: {} });
24
24
 
25
25
  try {
26
26
  await client.connect(transport);
@@ -62,6 +62,8 @@ test('MCP client connects over stdio and exercises tools, resources, and prompts
62
62
  });
63
63
 
64
64
  test('MCP client connects through the real Streamable HTTP handler at /mcp', { timeout: 30_000 }, async () => {
65
+ const previousApiKey = process.env.MCP_API_KEY;
66
+ process.env.MCP_API_KEY = 'kiku-mcp-test-key';
65
67
  const { handleApiRequest } = await import('../../apiRouter.js');
66
68
  const httpServer = createServer((req, res) => {
67
69
  void handleApiRequest(req, res).catch(error => {
@@ -80,15 +82,17 @@ test('MCP client connects through the real Streamable HTTP handler at /mcp', { t
80
82
  const discoveryRes = await fetch(`${baseUrl}/mcp`);
81
83
  assert.equal(discoveryRes.status, 200);
82
84
  const discoveryData = await discoveryRes.json();
83
- assert.equal(discoveryData.status, 'success');
84
- assert.equal(discoveryData.name, 'kiku');
85
- assert.equal(discoveryData.version, PACKAGE_VERSION);
85
+ assert.equal(discoveryData.status, 'success');
86
+ assert.equal(discoveryData.name, 'kiku');
87
+ assert.equal(discoveryData.version, PACKAGE_VERSION);
86
88
  assert.equal(discoveryData.endpoints.http, `${baseUrl}/mcp`);
87
89
  assert.equal(discoveryData.endpoints.sse, undefined);
88
90
  assert.equal(discoveryData.endpoints.message, undefined);
89
91
 
90
- const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
91
- const client = new Client({ name: 'kiku-http-mcp-test', version: PACKAGE_VERSION }, { capabilities: {} });
92
+ const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
93
+ requestInit: { headers: { 'X-API-Key': 'kiku-mcp-test-key' } },
94
+ });
95
+ const client = new Client({ name: 'kiku-http-mcp-test', version: PACKAGE_VERSION }, { capabilities: {} });
92
96
 
93
97
  try {
94
98
  await client.connect(transport);
@@ -101,6 +105,8 @@ test('MCP client connects through the real Streamable HTTP handler at /mcp', { t
101
105
  } finally {
102
106
  await client.close().catch(() => {});
103
107
  await new Promise(resolve => httpServer.close(resolve));
108
+ if (previousApiKey === undefined) delete process.env.MCP_API_KEY;
109
+ else process.env.MCP_API_KEY = previousApiKey;
104
110
  }
105
111
  });
106
112
 
@@ -0,0 +1,156 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
+ import { PACKAGE_VERSION } from '../../agent/version.js';
8
+ import { handleApiRequest } from '../../apiRouter.js';
9
+ import { MCP_QUIZ_CORRECT_ANSWERS } from '../../agent/mcpQuizData.js';
10
+
11
+ test('Dynamic MCP API Key flow: challenge -> speedhack guard -> 12-question quiz claim -> Streamable HTTP authentication', { timeout: 35_000 }, async () => {
12
+ const previousEnvKey = process.env.MCP_API_KEY;
13
+ process.env.MCP_API_KEY = 'mcp-static-fallback-key';
14
+
15
+ const httpServer = createServer((req, res) => {
16
+ void handleApiRequest(req, res).catch(error => {
17
+ res.statusCode = 500;
18
+ res.end(error.message);
19
+ });
20
+ });
21
+
22
+ await new Promise((resolve, reject) => {
23
+ httpServer.once('error', reject);
24
+ httpServer.listen(0, '127.0.0.1', resolve);
25
+ });
26
+
27
+ const address = httpServer.address();
28
+ const baseUrl = `http://127.0.0.1:${address.port}`;
29
+
30
+ let client = null;
31
+ let transport = null;
32
+
33
+ try {
34
+
35
+ const challengeRes = await fetch(`${baseUrl}/api/v1/mcp/quiz/challenge`);
36
+ assert.equal(challengeRes.status, 200);
37
+ const challengeJson = await challengeRes.json();
38
+ assert.equal(challengeJson.status, 'success');
39
+ assert.equal(challengeJson.data.totalQuestions, 12);
40
+ assert.equal(challengeJson.data.requiredScore, 12);
41
+ const challengeToken = challengeJson.data.challengeToken;
42
+ assert.ok(challengeToken && challengeToken.startsWith('kiku_mcp_chal_'));
43
+
44
+
45
+ const failNoTokenRes = await fetch(`${baseUrl}/api/v1/mcp/keys/claim`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ answers: [] })
49
+ });
50
+ assert.equal(failNoTokenRes.status, 403);
51
+
52
+
53
+ const correctAnswers = MCP_QUIZ_CORRECT_ANSWERS.map((ans, idx) => ({
54
+ qIndex: idx,
55
+ answerIndex: ans
56
+ }));
57
+ const speedhackRes = await fetch(`${baseUrl}/api/v1/mcp/keys/claim`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({
61
+ challengeToken,
62
+ answers: correctAnswers
63
+ })
64
+ });
65
+ assert.equal(speedhackRes.status, 403);
66
+
67
+
68
+ await new Promise(r => setTimeout(r, 2600));
69
+
70
+
71
+ const wrongAnswers = MCP_QUIZ_CORRECT_ANSWERS.map((ans, idx) => ({
72
+ qIndex: idx,
73
+ answerIndex: 99
74
+ }));
75
+ const failWrongAnsRes = await fetch(`${baseUrl}/api/v1/mcp/keys/claim`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({
79
+ challengeToken,
80
+ answers: wrongAnswers
81
+ })
82
+ });
83
+ assert.equal(failWrongAnsRes.status, 403);
84
+
85
+
86
+ const claimRes = await fetch(`${baseUrl}/api/v1/mcp/keys/claim`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({
90
+ challengeToken,
91
+ answers: correctAnswers
92
+ })
93
+ });
94
+ assert.equal(claimRes.status, 200);
95
+ const claimJson = await claimRes.json();
96
+ assert.equal(claimJson.status, 'success');
97
+ assert.equal(claimJson.data.tier, 'MCP VIP Tier');
98
+ assert.equal(claimJson.data.rateLimit, 1000);
99
+ const claimedKey = claimJson.data.apiKey;
100
+ assert.ok(claimedKey.startsWith('kiku_mcp_sk_'));
101
+ assert.ok(claimJson.data.claudeConfig?.mcpServers?.['kiku-remote']);
102
+
103
+
104
+ const replayRes = await fetch(`${baseUrl}/api/v1/mcp/keys/claim`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({
108
+ challengeToken,
109
+ answers: correctAnswers
110
+ })
111
+ });
112
+ assert.equal(replayRes.status, 403);
113
+
114
+
115
+ transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
116
+ requestInit: { headers: { 'X-API-Key': claimedKey } },
117
+ });
118
+ client = new Client({ name: 'kiku-dynamic-mcp-key-test', version: PACKAGE_VERSION }, { capabilities: {} });
119
+ await client.connect(transport);
120
+
121
+ const tools = await client.listTools();
122
+ assert.ok(tools.tools.length >= 10);
123
+ assert.ok(tools.tools.some(t => t.name === 'search_works'));
124
+
125
+ const callResult = await client.callTool({
126
+ name: 'search_works',
127
+ arguments: { year: 2026 }
128
+ });
129
+ assert.equal(callResult.isError, false);
130
+ const parsedContent = JSON.parse(callResult.content[0].text);
131
+ assert.equal(parsedContent.status, 'success');
132
+ assert.ok(parsedContent.count > 0);
133
+
134
+
135
+ const badKeyRes = await fetch(`${baseUrl}/mcp`, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'X-API-Key': 'kiku_mcp_sk_invalid_bogus_key'
140
+ },
141
+ body: JSON.stringify({
142
+ jsonrpc: '2.0',
143
+ method: 'initialize',
144
+ params: { protocolVersion: '2024-11-05', clientInfo: { name: 'bad-client', version: '1.0.0' } },
145
+ id: 1
146
+ })
147
+ });
148
+ assert.equal(badKeyRes.status, 401);
149
+
150
+ } finally {
151
+ process.env.MCP_API_KEY = previousEnvKey;
152
+ if (client) await client.close().catch(() => {});
153
+ if (transport) await transport.close().catch(() => {});
154
+ await new Promise(resolve => httpServer.close(resolve));
155
+ }
156
+ });
@@ -19,6 +19,7 @@ const PUBLIC_TOOL_NAMES = new Set([
19
19
  'global_search',
20
20
  'get_system_stats',
21
21
  'get_current_datetime',
22
+ 'get_user_location',
22
23
  'get_youtube_transcript',
23
24
  'search_news',
24
25
  'fetch_rss',
@@ -32,7 +33,7 @@ const OPTIONAL_WEB_TOOL_NAMES = new Set(['web_search', 'fetch_webpage', 'fetch_a
32
33
  const webToolsEnabled = process.env.KIKU_MCP_ENABLE_WEB_TOOLS === 'true';
33
34
 
34
35
  const OPTIONAL_CN_TOOL_NAMES = new Set(['search_chinese_web', 'search_bilibili', 'search_web_images']);
35
- const cnToolsEnabled = process.env.KIKU_ENABLE_CN_WEB_TOOLS === 'true';
36
+ const cnToolsEnabled = !['false', '0', 'off', 'no'].includes((process.env.KIKU_ENABLE_CN_WEB_TOOLS ?? '').trim().toLowerCase());
36
37
 
37
38
  const MUSIC_TOOL_NAMES = new Set([
38
39
  'search_youtube',
@@ -60,6 +61,11 @@ const PRIVILEGED_TOOL_NAMES = new Set([
60
61
  'resume_music',
61
62
  'next_track',
62
63
  'prev_track',
64
+ 'set_volume',
65
+ 'seek_music',
66
+ 'set_repeat_mode',
67
+ 'set_shuffle_mode',
68
+ 'toggle_video_mode',
63
69
  'add_to_queue',
64
70
  'remove_from_queue',
65
71
  'clear_queue',